@lotics/ui 43.3.2 → 43.5.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 (69) hide show
  1. package/AGENTS.md +3 -2
  2. package/MIGRATION.md +93 -0
  3. package/docs/catalog.md +128 -26
  4. package/docs/composition.md +380 -9
  5. package/docs/data_entry.md +25 -0
  6. package/docs/reviewing.md +477 -0
  7. package/docs/templates.md +9 -6
  8. package/examples/tpl_attendance.tsx +0 -1
  9. package/examples/tpl_item_list.tsx +109 -54
  10. package/examples/tpl_record.tsx +89 -4
  11. package/package.json +7 -3
  12. package/src/avatar.tsx +29 -29
  13. package/src/avatar.web.tsx +32 -31
  14. package/src/avatar_props.ts +66 -0
  15. package/src/avatar_tone.ts +79 -0
  16. package/src/button.tsx +36 -4
  17. package/src/checkbox.tsx +4 -1
  18. package/src/choice_list.tsx +5 -3
  19. package/src/color_tokens.ts +15 -14
  20. package/src/composer.tsx +3 -3
  21. package/src/control_surface.ts +37 -9
  22. package/src/copy_button.tsx +8 -1
  23. package/src/counter.tsx +1 -1
  24. package/src/data_grid.tsx +6 -3
  25. package/src/date_calendar.tsx +4 -2
  26. package/src/date_range_filter_field.tsx +5 -1
  27. package/src/date_segments_field.tsx +2 -2
  28. package/src/file_drop_target.web.tsx +2 -2
  29. package/src/file_dropzone.tsx +12 -6
  30. package/src/file_rows.tsx +22 -2
  31. package/src/file_thumbnail.tsx +19 -3
  32. package/src/font_family.ts +1 -1
  33. package/src/font_family.web.ts +1 -1
  34. package/src/funnel.tsx +1 -1
  35. package/src/icon_button.tsx +5 -2
  36. package/src/index.css +22 -16
  37. package/src/inline_edit.tsx +9 -10
  38. package/src/inline_files.tsx +6 -0
  39. package/src/json_panel.tsx +1 -1
  40. package/src/kpi_card.tsx +1 -1
  41. package/src/locale.tsx +1 -1
  42. package/src/markdown.css +5 -1
  43. package/src/member_chip.tsx +19 -2
  44. package/src/metric.tsx +12 -18
  45. package/src/number_input.tsx +2 -2
  46. package/src/option_picker.tsx +57 -0
  47. package/src/picker.tsx +2 -2
  48. package/src/pressable_row.tsx +15 -5
  49. package/src/progress_bar.tsx +1 -1
  50. package/src/radio_picker.tsx +2 -1
  51. package/src/search_input.tsx +12 -11
  52. package/src/sort_header.tsx +7 -3
  53. package/src/stacked_progress_bar.tsx +1 -1
  54. package/src/step_progress.tsx +5 -2
  55. package/src/switch.tsx +11 -6
  56. package/src/table.tsx +148 -16
  57. package/src/table_fit.ts +12 -0
  58. package/src/tabs.tsx +13 -1
  59. package/src/text.css +50 -18
  60. package/src/text.tsx +37 -40
  61. package/src/text_input_field.tsx +2 -2
  62. package/src/text_utils.ts +48 -6
  63. package/src/theme.ts +13 -0
  64. package/src/theme.web.ts +49 -0
  65. package/src/theme_vars.ts +113 -0
  66. package/src/type_ramp.ts +100 -0
  67. package/src/theme.tsx +0 -24
  68. package/src/theme.web.tsx +0 -79
  69. package/src/theme_context.ts +0 -107
@@ -0,0 +1,477 @@
1
+ # Reviewing a screen you built
2
+
3
+ The other docs say what good looks like. This one says how to find out whether you achieved it.
4
+
5
+ That gap is the reason this file exists. An agent cannot see the screen it just wrote any better
6
+ than it can see a bug it just introduced — "it looks fine" is the same claim as "it should work",
7
+ made with the same evidence. Every rule in [composition.md](./composition.md) was written the last
8
+ time it was broken, by someone who had read it. So the discipline is not *know the rules*, it is
9
+ **render it, measure it, and let the numbers say whether the rules held**.
10
+
11
+ Everything here is about a screen **that already renders**. Building one starts at
12
+ [catalog.md](./catalog.md) (which component) and [templates.md](./templates.md) (which shape).
13
+
14
+ ---
15
+
16
+ ## The method
17
+
18
+ **"Looks off" is a hypothesis, not a finding.** An impression cannot be compared across a screen
19
+ or re-checked after a fix. Extract computed values and let the table show the defect. Every
20
+ finding cites numbers.
21
+
22
+ 1. **Render it** — `lotics app dev` for an app, a preview route for a screen. Drive it with
23
+ browser automation that can evaluate JS in the page.
24
+ 2. **Run the two gates**, then the probes.
25
+ 3. **Fix, then re-measure.** A design fix is verified the way it was found.
26
+ 4. **Screenshot too.** Numbers say *what* is wrong; a picture says *that* something is, and some
27
+ defects live only in the picture — a mark floating over a tile that never loaded measures
28
+ perfectly and is obviously broken on sight. Take it even when the numbers are clean.
29
+
30
+ **Map raw CSS back to the ladder as you go.** A finding reads as `xs/500/muted`, not
31
+ `12px/500/rgb(82,82,91)`, so the treatment classes are visible at a glance. The rungs are in
32
+ [`type_ramp`](../src/type_ramp.ts); above 768px `lg`/`xl`/`xxl`/`xxxl` are 20/24/32/48 and below
33
+ it 18/22/28/32, so the same rung reports different pixels at the two widths — resolve against the
34
+ width you are measuring.
35
+
36
+ **Widths: measure at 1280 and 375.** Treatment bugs are width-independent on a screen that merely
37
+ REFLOWS — but a screen that FORKS (`useScreenSize().small`, a container query, a different
38
+ component per bucket) is a second screen running code the other width never executes, so a plain
39
+ defect can sit there forever while every measurement at the other width passes. Audit each fork as
40
+ its own screen.
41
+
42
+ **Read the width the component actually RECEIVES, never the viewport you set.** A gallery or docs
43
+ shell wraps it in its own nav, so "1280" may be rendering at 1060 and every conclusion is wrong in
44
+ the same direction. The kit measures the CONTAINER, not the screen — see AGENTS.md, Iron rules.
45
+
46
+ ---
47
+
48
+ ## Two gates, before any probe
49
+
50
+ A perfectly-treated element that should not exist still measures clean, and a wrong control cannot
51
+ be treated into a right one. Both of these are cheap and both outrank everything below.
52
+
53
+ ### 1. SUBTRACT
54
+
55
+ Per element, ask what QUESTION it answers and who is asking. A value nobody compares across rows
56
+ is not a column; a figure the summary already totals does not need repeating per row; a fact
57
+ derivable from its neighbour is a second copy that can disagree. Cut it, then re-ask whether the
58
+ screen got worse.
59
+
60
+ *The tell that you are subtracting too little:* reaching for a smaller size, a tighter gap, a
61
+ truncation or an abbreviation — each makes the excess harder to read instead of removing it.
62
+ "Cramped" is almost always COUNT, which no width fixes.
63
+
64
+ *Then count FACTS, not elements:* three well-made blocks all reporting that someone was absent is
65
+ one fact three times. → [composition.md](./composition.md) §"Touch & whitespace" (the trim test).
66
+
67
+ ### 2. RIGHT CONTROL
68
+
69
+ Name each control's SPECIES and justify it — against the ladder in
70
+ [data_entry.md](./data_entry.md) §"Choosing a CHOICE control" (by option count, not taste), then
71
+ against the `tpl_*` that already solved this shape.
72
+
73
+ A boolean is a switch, not a popover holding one checkbox. An exclusive choice belongs only where
74
+ the states really are exclusive — *independent capabilities that happen to be exclusive today*
75
+ bake today's combinations into the type. And check the species is not furniture from another
76
+ ALTITUDE: a list-row disclosure nested in a list row, a section header serving as a field label.
77
+ Each reviews clean in isolation and reads as clutter in place.
78
+
79
+ ---
80
+
81
+ ## The probes
82
+
83
+ Each names what to COLLECT and the SIGNATURE — what the numbers look like when it is wrong. The
84
+ rule behind it lives in the area doc named beside it; this file never restates one.
85
+
86
+ ### 1. Type inventory
87
+ **Collect** every text node as `rung / weight / ink`, with a sample of what wears each.
88
+
89
+ - **Range** = largest ÷ smallest. Under ~2× reads flat *at any amount of colour* — scale is the
90
+ one hierarchy device colour cannot substitute for. A register with no page band measures ~1.33×.
91
+ - **Weight is a font FAMILY here, not an axis.** Semibold is a separate, genuinely heavier file,
92
+ and the heading ramp is semibold at every rung — so semibold on a row subject, a value or a
93
+ label renders content at heading weight. State steps up in INK, never into the heading weight.
94
+ - **Same shape → same treatment.** Group every element by its visual SHAPE (label-over-value,
95
+ figure+caption, icon+row, chip) and check each group has ONE treatment. A reader parses shape
96
+ before meaning, so an unexplained difference reads as an accident however principled the reason.
97
+ - **Singletons.** A rung/weight/ink combination appearing ONCE either wants siblings or wants to
98
+ join an existing class. Pick the sibling set by STRUCTURAL ROLE, not visual resemblance.
99
+ - **Pairs.** For every primary+supporting pair, divide. A spread in the RATIOS is the finding, not
100
+ a spread in the sizes.
101
+ - **A label sharing a row with a flexible value: measure the LABEL across rows whose values differ
102
+ in LENGTH.** Whichever of the two cannot shrink forces the other to, so a value floored at its
103
+ longest word leaves the label absorbing every pixel and collapsing to a one-character column
104
+ many times taller than the row it names. Invisible wherever the values happen to fit — usually
105
+ the width you audited. The signature is the label's box CHANGING SHAPE row to row: collect width
106
+ and height per row and treat a spread as the finding, never the average.
107
+ - **Shrunk composites.** Where a component is sized by its container rather than its own scale,
108
+ measure the type INSIDE it, not the box — a badge dropped into a cell can scale its own label to
109
+ five pixels, internally consistent and invisible to every treatment probe. The fix is rarely a
110
+ bigger box; ask what the thing IS on that surface. But separate unreadable TYPE from unreadable
111
+ MARK before ruling: a badge's label never reaches the ladder at any row-scale size, so 5px
112
+ proves only that you measured a mark.
113
+
114
+ → [composition.md](./composition.md) §"Typography", §"The heading ramp".
115
+
116
+ ### 2. Ground, border and radius across siblings
117
+ **Collect** `background-color`, `border`, `border-radius` and height for every control in a group.
118
+
119
+ - **A singleton ground is a finding**, like a singleton type treatment: four buttons at `h=40`
120
+ with identical type, three with a ground and the destructive one transparent. Readers parse
121
+ box-vs-no-box before colour.
122
+ - **A SELECTED control needs a GROUND, not a heavier outline** — among white pills a 1px border
123
+ change is noise.
124
+ - **Tally chromatic grounds.** Disciplined is one or two hues plus greys. A hue with a single
125
+ member, from a family used nowhere else, is a library default nobody chose — the call site
126
+ usually mentions no colour at all.
127
+ - **A control riding a hoverable surface must be DARKER than it** — and hover BOTH to see it,
128
+ since reaching the control means crossing its host. Equal washes vanish exactly when pointed at;
129
+ a LIGHTER one reads as a hole rather than a target. Neither is visible at rest or when hovering
130
+ the control alone. `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` in `control_surface` are a step
131
+ past the row's own wash for exactly this; a hand-picked grey is the finding however plausible.
132
+ - **A pressable that does not NAVIGATE must not wear navigation ink.** Blue underlined text
133
+ promises a destination; a control opening a modal, a peek or an inline expansion is not one.
134
+ - **A semantic token passed where a raw value is expected fails SILENTLY and typechecks.** A prop
135
+ typed `color?: string` takes a CSS colour, not a design token, so `color="muted"` is an invalid
136
+ colour and the element falls back to full-strength ink while the compiler stays quiet. Diff the
137
+ computed `color`/`stroke` against the token you INTENDED, using neighbouring text that carries
138
+ the real token as the control.
139
+ - **A control drawing NOTHING at rest is text**, not a quiet control. Collect every pressable's
140
+ RESTING `background-color`, `border-width` and `text-decoration`: all three empty means the only
141
+ affordance is the hover wash, which is invisible to keyboard and touch. It measures perfectly
142
+ and has a real focus ring; what fails is that nobody presses it.
143
+
144
+ → [composition.md](./composition.md) §"Color discipline", §"Buttons & action labels", §"Every number is a door", §"Hover grammar".
145
+
146
+ ### 3. Rules — ask what each one SEPARATES
147
+ **Collect** every border/divider with weight, colour and width.
148
+
149
+ The test is not how big the break is. A rule between two rows divides **like from like** —
150
+ whitespace says that just as well, which is why a register can drop every internal rule and read
151
+ calmer. A rule under a column band divides **chrome from content**, and whitespace cannot say
152
+ that, because whitespace is already what separates the rows. Delete the like-from-like ones
153
+ however important the break feels. Backwards produces both failure modes: a grid of equal lines
154
+ that reads as a spreadsheet, and a lone survivor that becomes the loudest thing on a quiet surface.
155
+
156
+ Also: **the most important break must never be tighter than a routine one** — a hand-rolled rule
157
+ beside a `SectionStack` is the usual cause.
158
+
159
+ → [composition.md](./composition.md) §"The register's rhythm".
160
+
161
+ ### 4. Gaps — measure RATIOS, not sizes
162
+ **Collect** the gap WITHIN a group and BETWEEN groups, and divide. Near 1 is the defect however
163
+ reasonable each number looks alone — 10px inside and 12px between measures fine twice and still
164
+ reads as one cluster. One `gap` on a container makes every child a peer; wrap the group, then
165
+ space the groups. Worst where both items are small grey glyphs on one line, because nothing but
166
+ spacing distinguishes them.
167
+
168
+ → [composition.md](./composition.md) §"Touch & whitespace".
169
+
170
+ ### 5. State diff
171
+ **Collect** each control at rest, hover, focus, pressed and editing, then diff the boxes.
172
+
173
+ - A control is meant to change **paint** across its states and never **geometry**. So
174
+ `border`/`background`/`box-shadow` differing is the design; the box or the text moving is the
175
+ defect. Text that shifts, a border that thickens, a ring drawn twice, a tint on one path only —
176
+ two pieces of code own one property and happened to agree at rest. Every other probe reads a
177
+ RESTING screen, so all of them pass on a control that jumps 8px on focus. **Fix by giving the
178
+ property ONE owner** — a shared surface helper, a single frame kept across the mode swap — never
179
+ by nudging the second owner into agreement.
180
+ - **A themeable control must derive every state from its token.** Signature: correct at rest, a
181
+ fixed neighbouring shade under the pointer. Watch for an opaque overlay hiding a themed ground.
182
+ - **A prop's EFFECT must match its claim** — render each value and diff what actually moved
183
+ against what the prop says it changes. The fix is in the component, never the caller.
184
+ - **Measure a component against ITSELF under each optional prop** that should not change size — a
185
+ file row measured 37px static and 49px with an `onPress`, because only the pressable variant
186
+ carried the padding its wash needed. Same for a slot that may be empty.
187
+
188
+ → [composition.md](./composition.md) §"Typography" (the `size` law), §"Hover grammar".
189
+
190
+ ### 6. Alignment and drift
191
+ - **One left edge — the whole page, then the whole APP.** Headings, detail labels, prose, a
192
+ table's first column, a footer's totals, a dialog's title. Then measure the screen this one
193
+ OPENS: a list and its record are one reading column seen twice, and both must derive from ONE
194
+ constant rather than happening to match today. The offender is nearly always a row that padded
195
+ for its own wash instead of bleeding it — fix the row, then delete the compensators, which
196
+ become double-corrections the moment the row is right. Two exceptions are legitimate: a
197
+ self-contained overlay has nothing to align with, and an indent something VISIBLE occupies (a
198
+ checkbox, an ordinal, a mark) is explained.
199
+ - **A CENTRED child hides its own drift.** Centring puts a child's top at `(container − child)/2`,
200
+ so a child whose size varies with DATA moves its own first line between instances while the
201
+ container measures identical every time. It takes a container with SLACK to bite. Wherever a
202
+ container centres repeated children (table cells, list rows, toolbar controls, grid tiles),
203
+ collect the child's FIRST-line offset per instance; a constant container with a spread of
204
+ offsets is the signature. Fix by bounding the child that varies — never with a cross-axis knob
205
+ (it moves the content and leaves the chrome), and never by resizing the CONTAINER, which changes
206
+ both terms and leaves the spread.
207
+ - **A slot holding another component's part must be sized by that component's constant** — a
208
+ 24px mark in an 18px column sits 3px outside the surface.
209
+ - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
210
+ calling any of them right.
211
+
212
+ → [composition.md](./composition.md) §"Canvas & content column", §"No dead rows", §"The register's own craft".
213
+
214
+ ### 7. Surface walk — open it, press it, read it
215
+ A resting register is the cheapest thing to screenshot and the least likely to hold the defect.
216
+
217
+ - **Open what the row opens** and measure where the actionable field LANDS. An UNBOUNDED section
218
+ (a feed, a thread) above a bounded one buries it, and the burial deepens with use.
219
+ - **Press every control** and look at the state you land in. Commonest miss: a value rendering as
220
+ a coloured chip in a register and as bare grey text in its own EDITOR.
221
+ - **Diff a promoted field against where it came from.** Promoting means MOVING; a copy left behind
222
+ gives one field two editors.
223
+ - **Read every string in its SETTLED state**, not the streaming one.
224
+ - **Every state designed** — skeleton mirroring the final layout, empty, empty-SEARCH, error — and
225
+ no layout shift between them. → [catalog.md](./catalog.md) §"Status / feedback".
226
+ - **A comment stating a PRIORITY between two regions is a claim to VERIFY.** "When the two
227
+ compete, X gives way" is testable, and a region whose width is a CONSTANT cannot give way —
228
+ whatever sits beside it absorbs every squeeze, silently and in the opposite direction to the
229
+ stated intent. Find the rule in prose, then find the number implementing it.
230
+ - **Read the document OUTLINE**, and again AFTER fixing the type. Content from stored markdown
231
+ carries the tags ITS writer chose, so a heading inside a value announces as a peer of the page's
232
+ sections; sizing it down fixes what the screen SHOWS and nothing about what it ANNOUNCES.
233
+
234
+ ### 8. Data probe — count what is really there
235
+ - **Query the table before choosing a surface.** "Show thumbnails" is wrong for a corpus of nine
236
+ files and zero images. Images are identified by CONTENT (a grid), documents by NAME (a list) —
237
+ follow the majority.
238
+ - **Read the stored value before blaming the renderer.** A grey generic file badge can be correct
239
+ (the stored mime is `application/octet-stream`); grey status pills can be correct (the field has
240
+ no option colours). The fix is often config or a write path, not CSS.
241
+ - **The obvious column may be the empty one.** Reading only the field the composer writes ships a
242
+ feature empty on every row and correct in code review.
243
+ - **A fixture can fail in the generous direction too**: an image tile pointed at a dead URL renders
244
+ nothing, so the chip designed to survive a photo was never tested against one.
245
+ - **Grep rendered text for internal identifiers — first probe, every audit** —
246
+ `/\b(opt|fld|tbl|rec|wfl|mbr|app|wsp)_[A-Za-z0-9]{6,}/`. It renders, measures, typechecks and
247
+ passes every test, because the value IS a valid string, and no other probe looks for it. The
248
+ usual cause is one field serving two consumers: a select decoded to its option KEY because a
249
+ picker's `value` needs the key, then the same variable printed at the reader. **Fix by decoding
250
+ BOTH** — the key for the control, the label for the sentence — never with a local key→label map,
251
+ which is a second copy of the field's own options and drifts the first time one is renamed. The
252
+ kit's `optionPicker` exists for this; see [data_entry.md](./data_entry.md).
253
+ - **For every value, name its AUTHOR — then check that author can change it.** No measurement
254
+ finds a MISSING affordance. Prose a person typed is always editable; a transcript or a file
255
+ never is.
256
+ - **Ask of every value: could this exist in the data?** Name the column. No answer means the block
257
+ goes.
258
+
259
+ ### 9. The absence pass — what catches "bland"
260
+ Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
261
+ nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
262
+ this converges on *distinctive*.
263
+
264
+ - **Type range** (probe 1) — under 2× is flat regardless of colour.
265
+ - **Chromatic count** — one or two hues used many times reads calm; zero reads like a spreadsheet;
266
+ ten used once each reads cheap.
267
+ - **Identity marks** — on a screen whose subject is an entity, count them. Zero is the finding,
268
+ and so is N identical: a mark that looks the same on every row carries nothing, and it is the
269
+ largest, brightest thing there.
270
+ - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
271
+ view. Usually the real answer when treatment fixes keep not landing.
272
+ - **What is inherently visual in THIS data?** Where there is no photography — most work software —
273
+ the data is the imagery: identity marks, status colour, file-type badges, sparklines, proportion
274
+ bars, tabular figures.
275
+ - **What makes this belong to THIS product?** Default typeface, default greys, default accent,
276
+ default control shapes is a well-built generic screen, and no polish changes that. The fix is a
277
+ theme, not another treatment pass.
278
+
279
+ → [composition.md](./composition.md) §"Identity marks", §"Character comes from the DATA", §"Where the accent goes".
280
+
281
+ ### 10. Microcopy
282
+ - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
283
+ refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
284
+ read aloud as the relation it stands for, write the relation or split the line.
285
+ - **One job per element.** A label labels, an example demonstrates, a caption qualifies. Nothing
286
+ does double duty.
287
+ - **A shortened element still has to justify existing** — trimming makes it pass every length
288
+ probe, so re-ask gate 1 on the trimmed thing.
289
+ - **Structure must encode something true.** A rule, an eyebrow, a number, a badge, a colour each
290
+ assert that something is separate / labelled / ordered / status. A false assertion is decoration
291
+ that costs more than it gives.
292
+
293
+ → [composition.md](./composition.md) §"Microcopy".
294
+
295
+ ---
296
+
297
+ ## Reviewing an AI surface
298
+
299
+ An AI feature proposes and a person decides. These ask whether that is decidable. The full
300
+ contract is [ai_patterns.md](./ai_patterns.md); these are the ones a rendered screen can be
301
+ measured against.
302
+
303
+ - **Show the WHOLE thing with the change marked in place.** A proposal in its own card is
304
+ context-free; a figure is judgeable only beside the others it sits with. `DiffValue`/`DiffMark`
305
+ drop into any container.
306
+ - **Both values stay on screen** — the old one is the only evidence the correction is the right
307
+ SIZE.
308
+ - **UNKNOWN is not zero.** An unanswered figure must not share its treatment with an answered one,
309
+ and a removal's placeholder must not share the labels' muted tone.
310
+ - **A value the system supplied must say where it came from** — what evidence, from which source,
311
+ and that a model produced it. Two failure shapes: a filled control with no adjacent explanation,
312
+ and an explanation restating the value instead of the EVIDENCE for it. Corollary: deterministic
313
+ and probabilistic answers must not look alike.
314
+ - **Nothing auto-applies**, the commit names the outcome and count, and the count is re-checked
315
+ against the surface after every block you add.
316
+ - **Never pre-select a conflict candidate** — that is a decision the system made and attributed to
317
+ the person.
318
+ - **A change that is not to the VALUE must diff the thing that did change** — a document refiled
319
+ under a new type is the same bytes; put the diff on the classification.
320
+ - **A mark over an arbitrary ground needs its own ground** — a tint that reads on white is a smudge
321
+ on a photo, and the fixture has to contain the photo.
322
+
323
+ ---
324
+
325
+ ## Extraction snippets
326
+
327
+ Run each at 1280 and 375. The tables ARE the findings.
328
+
329
+ ### Type inventory — every run of language, in document order
330
+
331
+ Walks TEXT NODES rather than elements, so a run-in label and its sentence are separate rows
332
+ instead of one blended string.
333
+
334
+ ```js
335
+ () => {
336
+ const SZ = { '12px':'xs','14px':'sm','16px':'md','18px':'lg','20px':'lg','22px':'xl','24px':'xl','28px':'xxl','32px':'xxl/xxxl','48px':'xxxl' };
337
+ const INK = { 'rgb(24, 24, 27)':'default','rgb(82, 82, 91)':'muted','rgb(113, 113, 122)':'zinc-500','rgb(161, 161, 170)':'zinc-400','rgb(255, 255, 255)':'inverted' };
338
+ const out = [];
339
+ const walk = (n) => {
340
+ if (n.nodeType === 3 && n.textContent.trim()) {
341
+ const cs = getComputedStyle(n.parentElement);
342
+ const rg = document.createRange(); rg.selectNode(n); const r = rg.getBoundingClientRect();
343
+ if (r.height) out.push({
344
+ t: n.textContent.trim().slice(0, 34),
345
+ y: Math.round(r.y), x: Math.round(r.x),
346
+ rung: SZ[cs.fontSize] || cs.fontSize, w: cs.fontWeight,
347
+ ink: INK[cs.color] || cs.color,
348
+ });
349
+ }
350
+ n.childNodes.forEach(walk);
351
+ };
352
+ walk(document.body);
353
+ return out.sort((a, b) => a.y - b.y);
354
+ }
355
+ ```
356
+
357
+ Then **count the treatments**: collapse to `rung/weight/ink` and tally. A combination with one
358
+ member is a singleton. Two combinations doing the same job is the inconsistency you were sent to
359
+ find.
360
+
361
+ **Skip `SCRIPT`/`STYLE`/`TITLE`/`META`/`LINK`/`NOSCRIPT` and check `checkVisibility()`** in any
362
+ variant that walks ELEMENTS rather than text nodes. Those tags hold text nodes the reader never
363
+ sees, and counting them produces confident nonsense — a page "with eight nodes in Times New Roman"
364
+ that has none, or a clean tally on a screen with an untracked component still on it.
365
+
366
+ ### Separator beats — is the biggest boundary the widest?
367
+
368
+ ```js
369
+ () => {
370
+ const rules = [...new Set([...document.querySelectorAll('div')]
371
+ .filter(e => { const r = e.getBoundingClientRect(); return r.height <= 1.5 && r.width > 200; })
372
+ .map(e => Math.round(e.getBoundingClientRect().y)))].sort((a, b) => a - b);
373
+ const blocks = [...document.querySelectorAll('*')]
374
+ .filter(e => e.children.length === 0 && e.textContent.trim())
375
+ .map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom) }; });
376
+ return rules.map(y => ({
377
+ rule: y,
378
+ above: y - Math.max(...blocks.filter(o => o.b <= y).map(o => o.b), -Infinity),
379
+ below: Math.min(...blocks.filter(o => o.y >= y).map(o => o.y), Infinity) - y,
380
+ }));
381
+ }
382
+ ```
383
+
384
+ Unequal `above`/`below` on one rule, or a rule with a visibly tighter beat than its neighbours, is
385
+ the finding. A hand-rolled `Divider` sitting in a parent's uniform `gap` next to a `SectionStack`
386
+ is the usual cause.
387
+
388
+ ### Clipping, overflow, collision
389
+
390
+ ```js
391
+ () => ({
392
+ pageOverflowsX: document.documentElement.scrollWidth > window.innerWidth,
393
+ clipped: [...document.querySelectorAll('*')]
394
+ .filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflow !== 'visible')
395
+ .map(e => ({ t: e.textContent.trim().slice(0, 40), w: e.clientWidth, needs: e.scrollWidth,
396
+ ellipsis: getComputedStyle(e).textOverflow })),
397
+ })
398
+ ```
399
+
400
+ `ellipsis: "clip"` on a clipped element means the text ends mid-word with nothing marking the cut.
401
+ Note the RN-Web trap: once a `Text` contains a nested pressable, `numberOfLines` degrades to bare
402
+ `overflow: hidden` and loses the ellipsis.
403
+
404
+ ### Grouping — is one `gap` flattening the hierarchy?
405
+
406
+ ```js
407
+ (sel) => [...document.querySelector(sel).children].map(c => {
408
+ const r = c.getBoundingClientRect();
409
+ return { t: c.textContent.trim().slice(0, 40), y: Math.round(r.y), h: Math.round(r.height) };
410
+ })
411
+ ```
412
+
413
+ List a stack's direct children and ask which BELONG together. If the answer is not "all of them",
414
+ one gap is serving several relationships and the screen will read as blended.
415
+
416
+ ### Interaction states — does the control move when you touch it?
417
+
418
+ Snapshot the SAME selector four times — at rest, hovered, focused, and while editing — then diff.
419
+
420
+ ```js
421
+ (sel) => [...document.querySelectorAll(sel)].map(e => {
422
+ const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
423
+ const inner = e.querySelector('input, textarea, [contenteditable]') || e;
424
+ const ir = inner.getBoundingClientRect();
425
+ return {
426
+ box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
427
+ text: [Math.round(ir.x), Math.round(ir.y)],
428
+ border: `${cs.borderTopWidth} ${cs.borderTopColor}`,
429
+ bg: cs.backgroundColor,
430
+ shadow: cs.boxShadow,
431
+ };
432
+ })
433
+ ```
434
+
435
+ `border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect. The usual
436
+ cause is two components spending the same inset or the same padding — a wrapper AND the input both
437
+ paying `CONTROL_TEXT_INSET`, a focus ring owned by both a shell and a nested pressable.
438
+
439
+ RN-Web trap: a frame REBUILT on the mode swap (view node unmounted, edit node mounted) never
440
+ receives `mouseenter`, because the pointer is already stationary over it — so hover state silently
441
+ dies at the moment of the swap and reads as a flash. Keep one node across the swap rather than
442
+ restoring the state by hand.
443
+
444
+ Driving the kit's own anatomies — a `PressDoor` row, a portalled overlay, a custom pointer drag —
445
+ has three gotchas of its own: [testing.md](./testing.md).
446
+
447
+ ---
448
+
449
+ ## What measurement cannot see
450
+
451
+ A screenshot shows *that* something is wrong when every number looks defensible. But it will not
452
+ show you a disclosure you did not expand, or copy that only lies in its terminal state (a settled
453
+ row still saying "Loading…" measures perfectly). Nor a flattering fixture — compare the preview
454
+ data's SHAPE against what the real source emits before trusting any of it.
455
+
456
+ ---
457
+
458
+ ## When you find something
459
+
460
+ - **Fix the CLASS, not the instance — and grep by the JOB, not the styling.** Two controls with
461
+ different labels, different call sites and no shared string can be the same species doing the
462
+ same job. Name the job in one phrase ("names a column", "commits the row", "opens a peek") and
463
+ search for everything else doing it. Fixing the tab in front of you is what leaves the same
464
+ field rendering two ways on two tabs — the inconsistency users actually report.
465
+ - **Three fixes in one spot means the ARRANGEMENT is the defect.** Count repairs, not their
466
+ difficulty; the third is the signal they were never independent.
467
+ - **Prefer changing the SHAPE over changing the weight.** Restructuring states a difference;
468
+ nudging a weight whispers it, and a whisper reads as a slip.
469
+ - **When the kit is the offender, fix the kit.** An app-local override is a fork, and a component
470
+ built beside one that nearly fits is a fork that drifts. Kit fixes land in `src/` plus the owning
471
+ `docs/<area>.md`, the `AGENTS.md` index line, and a version bump — docs reach apps only on
472
+ publish.
473
+ - **Never resolve a finding with "the docs say X."** Resolve it with what makes the screen better,
474
+ then push the answer back into the docs. A written rule cannot report that it produces a bad
475
+ screen, so a person has to. Where the docs ARE load-bearing is IDENTITY: the fonts, the type
476
+ ramp, the `Icon` set, one accent per screen — inventing a palette or a typeface is the defect,
477
+ not the craft.
package/docs/templates.md CHANGED
@@ -197,8 +197,11 @@ The one work-execution list shape; it subsumes approvals, dispatch, batch-buildi
197
197
  screens — register, per-row action, gated selection, and act-on-many in one. The page:
198
198
 
199
199
  - **One toolbar row** — search + a status `Select` + facet `FilterChip`s LEFT, the New CTA
200
- RIGHT; a light `SummaryLine` of the filtered view below.
201
- - **A sortable, `Divider`-separated `Table`** where the record's SUBJECT is the first column
200
+ RIGHT; then a light `SummaryLine` of the filtered view BELOW it, above the rows — the counts are what the toolbar just produced, so they read after it, not before.
201
+ - **A sortable `Table`** rows separated by their own height and the hover wash, with the one
202
+ hairline under the column band. It sets no row height: the register's rhythm is the kit's,
203
+ and a template that overrode it would teach the override. → [composition.md](./composition.md)
204
+ §"The register's rhythm". The record's SUBJECT is the first column —
202
205
  who or what the record is FOR, because that is what a reader scans for and what they say out
203
206
  loud ("the Harbor Freight one", never "RC-2026-0041"). The KEY is how the SYSTEM refers to the
204
207
  record, so it rides the identity cell's supporting line, muted, where the other supporting
@@ -208,14 +211,14 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
208
211
  itself the same way, or the two disagree about what a record is called. Every row carries a
209
212
  leading checkbox; a row that can't take the bulk action gets a
210
213
  **disabled** checkbox (the same gating as any blocked line). A row's status is
211
- `Badge variant="dot"` — never the tonal `Badge` (that's the drawer/header twin; → [composition
212
- grammar §"Status indicators have a WEIGHT"](./composition.md)) — and the SAME dot vocabulary
214
+ `Badge variant="dot"` — never the tonal `Badge` (that's the drawer/header twin; → [composition.md](./composition.md)
215
+ §"`Badge` is for STATUS only") — and the SAME dot vocabulary
213
216
  drives the status facet. A cell that carries supporting detail is a HIERARCHY, not two peers:
214
217
  the identity on top (`size="sm"`, `weight="medium"` when it IS the row's name), its supporting
215
218
  value beneath as `size="xs" color="muted"`, `gap: 2` — and a category / type / attribute goes
216
219
  on THAT line, never a second chip beside the name (which reads as its peer, or louder when
217
- colored, inverting what the row is scanned by; → [composition grammar §`Badge` is for STATUS
218
- only](./composition.md)). Every value in a row is SELECTABLE, so a reader copies any of them
220
+ colored, inverting what the row is scanned by; → [composition.md](./composition.md)
221
+ §"`Badge` is for STATUS only"). Every value in a row is SELECTABLE, so a reader copies any of them
219
222
  without chrome; the supporting phone additionally carries a **`CopyButton`** as the worked
220
223
  example of a verb riding its value — the one value copied often enough to earn a control, and
221
224
  in its own cell rather than the trailing gutter, which two values in a cell give nothing to
@@ -135,7 +135,6 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
135
135
  <TableRow
136
136
  onPress={onPress}
137
137
  selected={selected}
138
- minHeight={64}
139
138
  accessibilityLabel={`Open ${nv.ten}'s day`}
140
139
  trailing={<HomNayMenu nv={nv} />}
141
140
  >