@lotics/ui 45.10.0 → 46.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/AGENTS.md +70 -137
- package/MIGRATION.md +27 -0
- package/docs/ai_patterns.md +166 -160
- package/docs/catalog.md +223 -287
- package/docs/composition.md +394 -518
- package/docs/data_entry.md +109 -155
- package/docs/reviewing.md +39 -55
- package/docs/templates.md +459 -423
- package/docs/testing.md +3 -7
- package/package.json +1 -1
- package/src/agent_progress.tsx +5 -4
- package/src/agent_run.tsx +218 -44
- package/src/agent_run_pane.tsx +5 -0
- package/src/agent_transform.ts +34 -0
- package/src/locale.tsx +3 -7
- package/src/pressable_row.tsx +7 -5
- package/src/table.tsx +15 -14
package/docs/composition.md
CHANGED
|
@@ -12,62 +12,53 @@ them produces off-system UI.
|
|
|
12
12
|
|
|
13
13
|
**If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
|
|
14
14
|
has looked the same in every carrier's app for twenty years: an ordered set of stages, where it is
|
|
15
|
-
now, when each was stamped, and where it is stuck. A ledger
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
now, when each was stamped, and where it is stuck. A ledger, a calendar, a seating chart, a
|
|
16
|
+
pipeline, a boarding pass — each is a form the reader has already learned somewhere else, and
|
|
17
|
+
matching it means they arrive knowing how to read the screen.
|
|
18
18
|
Go and look at how the industry does it before designing something new; a shape that is unfamiliar
|
|
19
19
|
in a domain that has a familiar one is a cost paid by every user, forever, to no end.
|
|
20
20
|
|
|
21
21
|
That is a licence, not a leash. **The rules in this document constrain treatment, not invention.**
|
|
22
22
|
Where the job needs a component the kit does not have, build it — the catalog is where solved
|
|
23
|
-
problems live, not a ceiling on what a screen may be
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
ramp, colours, spacing and states. Reach for the shape the work deserves, then dress it in the
|
|
27
|
-
kit's clothes.
|
|
23
|
+
problems live, not a ceiling on what a screen may be. The bar for a new composition is that it is
|
|
24
|
+
the RIGHT shape for the subject and that it obeys the language below: the kit's type ramp, colours,
|
|
25
|
+
spacing and states.
|
|
28
26
|
|
|
29
27
|
**Which rules bind, and which advise.** Read every rule here for what it ENCODES, because the two
|
|
30
28
|
kinds are answered differently:
|
|
31
29
|
|
|
32
30
|
- **A rule that prevents a known HARM binds.** A page that scrolls sideways on a phone, an act that
|
|
33
31
|
cannot be undone, a figure that is typed where it should be computed, a machine's guess rendered
|
|
34
|
-
like a person's decision, a control a keyboard cannot reach — these are settled
|
|
35
|
-
for once, and re-opening them just re-buys the defect.
|
|
32
|
+
like a person's decision, a control a keyboard cannot reach — these are settled.
|
|
36
33
|
- **A rule that picks among valid FORMS advises.** Which shape fits this subject, how a set is cut,
|
|
37
34
|
what the reader sees first — the space of jobs is unbounded and the best answer is regularly one
|
|
38
35
|
no document here has named. Treat these as the accumulated experience they are, then decide.
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
what produces a shape nobody has seen; reaching for the nearest template is what produces the
|
|
45
|
-
fiftieth register with a header.
|
|
37
|
+
**Most of what reads as inventive is this document's FIRST rule being followed well, not escaped**:
|
|
38
|
+
a set whose tabs are the stages its records pass through is not a departure from "name what the
|
|
39
|
+
subject IS", it is that rule landing. Naming the subject honestly is what produces a shape nobody
|
|
40
|
+
has seen; reaching for the nearest template is what produces the fiftieth register with a header.
|
|
46
41
|
|
|
47
42
|
**Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
|
|
48
43
|
through ordered places is a rail; work assigned to finite resources over time is a board; a
|
|
49
44
|
sequence of events is a timeline; a population whose point is its deviant minority is a queue
|
|
50
45
|
sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
|
|
51
|
-
should have been a rail
|
|
52
|
-
|
|
53
|
-
word comes after which.
|
|
46
|
+
should have been a rail says nothing about order or progress, because a status column reports one
|
|
47
|
+
word and leaves the reader to know which word comes after which.
|
|
54
48
|
|
|
55
49
|
**A hierarchy in the DATA does not oblige a hierarchy in the UI.** A parent id, a materialized
|
|
56
50
|
path, a folder column — each is a real structure, and none of them decides the screen's form. Ask
|
|
57
51
|
what the reader DOES with the set: a population they walk (a chart of accounts, a bill of
|
|
58
|
-
materials
|
|
59
|
-
|
|
52
|
+
materials) is a tree or a drill; one they scan and search is a register, and the path becomes the
|
|
53
|
+
row's supporting line rather than a place to travel to. The
|
|
60
54
|
distinction is who maintains the structure. Where something else writes it — an importer, an
|
|
61
55
|
integration, an agent — a drill-down makes the reader navigate a shape they did not choose and
|
|
62
56
|
cannot see the whole of, while a flat register with the address on the row costs one line and
|
|
63
57
|
loses nothing. Build the tree when walking it IS the task, not because the column exists.
|
|
64
58
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
first is about not duplicating primitives** — a one-off composition needs no new component, so the
|
|
69
|
-
kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
|
|
70
|
-
subject into rows.
|
|
59
|
+
**Reuse first is about not duplicating primitives** — a one-off composition needs no new component,
|
|
60
|
+
so the kit lacking a device is a reason to compose one from what it has, not a reason to flatten
|
|
61
|
+
the subject into rows.
|
|
71
62
|
|
|
72
63
|
## Canvas & content column
|
|
73
64
|
|
|
@@ -78,11 +69,22 @@ subject into rows.
|
|
|
78
69
|
the same left edge as the title above it and the actions below it. Never hand-pad inside one,
|
|
79
70
|
and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
|
|
80
71
|
only the surface knows which it is.
|
|
72
|
+
- **The responsive one is READ, never re-derived — `useDialogGutter()`, no argument.** Inside
|
|
73
|
+
a `Dialog` that is what the header, the scroll area, the footer and any pane a caller drops
|
|
74
|
+
in all call. The `Dialog` resolves `small ? 16 : 24` ONCE, off the SCREEN, and publishes it.
|
|
75
|
+
Centralising the VALUE was not enough and that mistake shipped: a `dialogGutter(small)` left
|
|
76
|
+
every band to answer `small` for itself, so the header, scroll area and footer asked
|
|
77
|
+
`useScreenSize()` while the pane inside asked `useContainerSize()` — two honest answers to
|
|
78
|
+
one question, and the run's rows sat out of line with the dialog's own title. A band that
|
|
79
|
+
measures its own box is measuring the wrong box. Outside a dialog the hook falls back to the
|
|
80
|
+
screen, the same signal a dialog uses, so the two paths cannot disagree about what "small"
|
|
81
|
+
means. The `Drawer`'s counterpart is a constant, `DRAWER_GUTTER`, because that surface's
|
|
82
|
+
number does not move.
|
|
81
83
|
- **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
|
|
82
84
|
ends short of the surface with dead space beneath it and its last row clipped. A pinned
|
|
83
|
-
heading above a scrolling list therefore takes the
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
heading above a scrolling list therefore takes the overlay's BARE slot and puts the gutter on
|
|
86
|
+
the pinned part and on the scroller's `contentContainerStyle`. Same for a full-bleed band,
|
|
87
|
+
which wants no gutter at all.
|
|
86
88
|
- **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
|
|
87
89
|
sized to the job, with `gap: 16` between top-level bands/cards:
|
|
88
90
|
|
|
@@ -102,17 +104,16 @@ constant rather than happening to match today.
|
|
|
102
104
|
The offender is nearly always a row that PADDED for its own hover wash instead of bleeding it
|
|
103
105
|
(`ROW_WASH_BLEED`), which pushes its content in by the bleed while everything above it stays put.
|
|
104
106
|
Fix the row, then DELETE the compensators that were added around it — once the row is right they
|
|
105
|
-
become double-corrections
|
|
106
|
-
|
|
107
|
-
menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies — a
|
|
107
|
+
become double-corrections. Two exceptions are legitimate and neither needs a fix: a
|
|
108
|
+
self-contained overlay (a menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies — a
|
|
108
109
|
checkbox, an ordinal, an identity mark — is explained by the thing occupying it.
|
|
109
110
|
|
|
110
111
|
**A row beat belongs to the PAGE, not to the row.** Collect every row primitive's height on one
|
|
111
112
|
screen before calling any of them right — the kit's own defaults do not agree with each other,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
each picked for its own surface: `ListItem` is 64, a ruled `Table` row is 52, a `Button`-height
|
|
114
|
+
control is 40. Stack three of those in one column and it reads as three rhythms, each defensible
|
|
115
|
+
alone. The page picks one beat and the primitives take it (`minHeight` on the row, `spacing` on
|
|
116
|
+
the table).
|
|
116
117
|
|
|
117
118
|
## The heading ramp — one construct per altitude, no drift
|
|
118
119
|
|
|
@@ -132,25 +133,22 @@ restyle a heading level per-page.
|
|
|
132
133
|
20px bar — a heading shouting over the pane that holds it. The CONTAINER publishes the altitude
|
|
133
134
|
and one `SectionHeadingTitle` answers to it: `##` on a page, `####` inside `DrawerScrollArea` /
|
|
134
135
|
`DialogScrollArea` / `ModalBody` / `PopoverContent`. Write the same heading either way; there is
|
|
135
|
-
no per-surface variant to remember
|
|
136
|
-
their heading will sooner or later be wrong about it.
|
|
136
|
+
no per-surface variant to remember.
|
|
137
137
|
|
|
138
138
|
Three consequences worth knowing before you compose:
|
|
139
139
|
|
|
140
140
|
- **A whole PAGE surface docked in an overlay keeps the page rung.** The master-detail register
|
|
141
141
|
puts a full record screen in a drawer; that screen brings its own `#` identity band, carries
|
|
142
|
-
its own gutters, and therefore sits in the container's BARE slot rather than the scroll area
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
its own gutters, and therefore sits in the container's BARE slot rather than the scroll area.
|
|
143
|
+
Its sections stay `##` and its subsections stay `###`. Nothing to pass; put an ordinary pane
|
|
144
|
+
body in the scroll area and a page surface in the bare slot, as the gutter contract already
|
|
145
|
+
asks.
|
|
146
146
|
- **A pane you build YOURSELF declares its own altitude.** A docked side panel is not an overlay
|
|
147
147
|
and uses none of those regions, but its headings must not shout across 400px either — wrap its
|
|
148
148
|
content in `<HeadingAltitudeContext.Provider value="panel">` (`@lotics/ui/heading_altitude`).
|
|
149
|
-
A rule scoped to the containers the kit happens to render is an allowlist short by however many
|
|
150
|
-
panes the app builds.
|
|
151
149
|
- **In a pane, `####` is the FLOOR.** `SubsectionHeadingTitle` has one altitude (`###` lg) and
|
|
152
150
|
would out-rank the section holding it, so a named group inside a pane's section is
|
|
153
|
-
`Text size="sm" weight="semibold"
|
|
151
|
+
`Text size="sm" weight="semibold"`.
|
|
154
152
|
|
|
155
153
|
Nesting never compounds: a popover opened inside a drawer is one rung down, not two.
|
|
156
154
|
|
|
@@ -160,13 +158,11 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
160
158
|
hierarchy device colour cannot substitute for. Restoring a `Text size="xxl" weight="semibold"`
|
|
161
159
|
title takes the same screen to ~2.7x. It also gives the page somewhere to land: without it the
|
|
162
160
|
first thing on the screen is a search input, which is chrome.
|
|
163
|
-
Yes, the title usually repeats the nav. Accept the repetition —
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
register's counts are not that line either: they are the result of the TOOLBAR, so they belong
|
|
169
|
-
under it — see § Summaries.
|
|
161
|
+
Yes, the title usually repeats the nav. Accept the repetition — the alternative (inventing a
|
|
162
|
+
title the nav does not say) is worse.
|
|
163
|
+
**A second line carries LIVE STATE, never a gloss** explaining the WIDGET. A register's counts
|
|
164
|
+
are not that line either: they are the result of the TOOLBAR, so they belong under it — see
|
|
165
|
+
§ Summaries.
|
|
170
166
|
Right side: the screen's ONE primary action and/or a period filter — never a
|
|
171
167
|
summary `Badge` (those belong to the KPI strip).
|
|
172
168
|
- **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
|
|
@@ -177,10 +173,9 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
177
173
|
lowest heading rank — and neither of the two constructs it sits between: `CardHeaderTitle`
|
|
178
174
|
(`sm`) labels a CONTAINER of data, whereas here the name IS the content; `RecordSummary`'s `#`
|
|
179
175
|
is a page/drawer rung, and a page rung inside a popover flattens the very altitude distinction
|
|
180
|
-
the ramp exists to keep.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
register with no page band. Its supporting line takes the rung that gives it SIBLINGS (the
|
|
176
|
+
the ramp exists to keep. A body-size name leaves the panel's subject separated from its own
|
|
177
|
+
supporting line by WEIGHT alone, which is a separate font file rather than a step, and the whole
|
|
178
|
+
surface then measures ~1.17x. Its supporting line takes the rung that gives it SIBLINGS (the
|
|
184
179
|
other muted lines in the same block), which is what "one rung below" is serving; applied
|
|
185
180
|
literally against that it produces a singleton treatment instead.
|
|
186
181
|
- **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
|
|
@@ -190,14 +185,12 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
190
185
|
below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
|
|
191
186
|
drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
|
|
192
187
|
up whatever is left, taking the `description` down with it. A label that repeats the heading
|
|
193
|
-
verbatim is the usual way this gets long enough to bite
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
|
|
197
|
-
not just a wordier button. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
|
|
188
|
+
verbatim is the usual way this gets long enough to bite. The button-label law already asks for
|
|
189
|
+
2–4 words; a heading-row control is the case where skipping it has a measurable width cost.
|
|
190
|
+
A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
|
|
198
191
|
paged, or long enough that the reader would have to scan to know. Over a list or thread
|
|
199
|
-
rendered whole directly beneath its own heading it restates what is already in view
|
|
200
|
-
|
|
192
|
+
rendered whole directly beneath its own heading it restates what is already in view. The page
|
|
193
|
+
column is a **`SectionStack`** — it owns the
|
|
201
194
|
between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
|
|
202
195
|
null/false children are skipped so a conditional section never leaves a stray hairline). The
|
|
203
196
|
`Divider` NEVER goes directly under a heading — that orphans the title from its own content.
|
|
@@ -207,10 +200,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
207
200
|
sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
|
|
208
201
|
- **Pane section title** — the FOURTH rung, taken automatically by `SectionHeadingTitle`
|
|
209
202
|
inside a pane's content region. Same construct, same `icon`/`description`/`info` slots;
|
|
210
|
-
only the type size steps down, and nothing at the call site says so.
|
|
211
|
-
|
|
212
|
-
occupies — so a panel holding a multi-section surface otherwise ties with, or is outranked by,
|
|
213
|
-
its own content. Compose it with the SUBSECTION layout (`Subsection` › `SubsectionHeading` ›
|
|
203
|
+
only the type size steps down, and nothing at the call site says so.
|
|
204
|
+
Compose it with the SUBSECTION layout (`Subsection` › `SubsectionHeading` ›
|
|
214
205
|
this, in a `SubsectionStack`): the 12px body gap and 32px beat are already right for a panel.
|
|
215
206
|
The ladder stays ABSOLUTE — this is one more fixed rung, never a `size`/`compact` knob.
|
|
216
207
|
- **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
|
|
@@ -231,11 +222,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
231
222
|
to its own section-level heading just to separate it.
|
|
232
223
|
- **Eyebrow vs group lead — a label is one or the other, and the difference is what it
|
|
233
224
|
LABELS.** Each is a COMPONENT — `@lotics/ui/eyebrow` and `@lotics/ui/group_lead` — so the call
|
|
234
|
-
site DECLARES which one it is rather than re-deriving a rung from feel
|
|
235
|
-
|
|
236
|
-
way; a shipped app then labelled three act-on-it blocks at the eyebrow's `xs` muted, which is
|
|
237
|
-
the failure the prose could not prevent. Neither takes a `size`, `weight` or `transform`: the
|
|
238
|
-
props a component omits are the rule it enforces.
|
|
225
|
+
site DECLARES which one it is rather than re-deriving a rung from feel. Neither takes a `size`,
|
|
226
|
+
`weight` or `transform`: the props a component omits are the rule it enforces.
|
|
239
227
|
|
|
240
228
|
| | Eyebrow | Group lead |
|
|
241
229
|
|---|---|---|
|
|
@@ -273,11 +261,9 @@ altitude (`SubsectionHeading`, `CardHeader`).
|
|
|
273
261
|
|
|
274
262
|
The rule is about a position that does not MOVE. Put the add under the register it extends and
|
|
275
263
|
where the reader looks for it depends on how many rows there already are — past a screenful the
|
|
276
|
-
verb is off-screen, and on an empty list there is no last row to sit under
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
and the view controls — so the add belongs on it, in the same spot whether the section holds
|
|
280
|
-
nought or forty.
|
|
264
|
+
verb is off-screen, and on an empty list there is no last row to sit under. The heading row is the
|
|
265
|
+
section's control line — it already carries the meta and the view controls — so the add belongs on
|
|
266
|
+
it, in the same spot whether the section holds nought or forty.
|
|
281
267
|
|
|
282
268
|
**When the row runs out of width, the VERB wraps — the title never gives way.** The title names
|
|
283
269
|
the section; the verb is the thing that can move. Getting this backwards is easy to ship because
|
|
@@ -285,12 +271,10 @@ RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title be
|
|
|
285
271
|
holds its intrinsic width is the half that collapses, and at phone width the heading breaks one
|
|
286
272
|
word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
|
|
287
273
|
their title column for this; do not add a second `flex: 1` spacer beside a title that already has
|
|
288
|
-
one, which halves its share again
|
|
274
|
+
one, which halves its share again.
|
|
289
275
|
|
|
290
276
|
**Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
|
|
291
|
-
bar** — an attachment popover, a dialog body, any self-contained widget.
|
|
292
|
-
to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
|
|
293
|
-
can return to, and on a surface with no heading the bar IS that place. `FilesEditorUpload` is for
|
|
277
|
+
bar** — an attachment popover, a dialog body, any self-contained widget. `FilesEditorUpload` is for
|
|
294
278
|
exactly this case; inside a section the host puts its own `Button` on the heading instead, because
|
|
295
279
|
`FilesEditor` provides the context and the heading is outside it — and a file widget rendering a
|
|
296
280
|
`SectionHeading` would own something the section owns. Worked both ways in `tpl_record` (Files,
|
|
@@ -298,19 +282,15 @@ Photos) and `tpl_task_board` (the attachment popover).
|
|
|
298
282
|
|
|
299
283
|
**A header CHECKBOX is legible only when it heads a COLUMN.** A table's select-all works because
|
|
300
284
|
it sits at the same x as every row's box beneath it — the column is what turns a 24px tick into
|
|
301
|
-
"all of these". A grid has no such column, so the same control in a section heading heads
|
|
302
|
-
nothing: it is a transparent 24×24 at radius 6 standing between 40px grounded buttons at radius
|
|
303
|
-
10, off their baseline, differing on every axis at once, and shape is read before meaning. **A
|
|
285
|
+
"all of these". A grid has no such column, so the same control in a section heading heads nothing. **A
|
|
304
286
|
grid's select-all is a BUTTON** ("Select all" / "Deselect all"), which is what
|
|
305
287
|
`FilesEditorSelectAll` ships. Nothing is lost by giving up the tri-state: `indeterminate` says
|
|
306
|
-
"some", and the `FloatingActionBar` beside it already says WHICH some as an exact count
|
|
307
|
-
glyph was approximating a number that was on screen.
|
|
288
|
+
"some", and the `FloatingActionBar` beside it already says WHICH some as an exact count.
|
|
308
289
|
|
|
309
290
|
While a mode is ON, the heading row holds only that mode's controls; the section's own ADD stands
|
|
310
291
|
down until it is off. Otherwise a `primary` add sits in a row that is entirely about the
|
|
311
292
|
selection, competing with the promoted act in the action bar — two accents on one screen. At rest
|
|
312
|
-
the add returns to the right edge where the law puts it, and drag/paste
|
|
313
|
-
no path actually closes.
|
|
293
|
+
the add returns to the right edge where the law puts it, and drag/paste keep working throughout.
|
|
314
294
|
|
|
315
295
|
- **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
|
|
316
296
|
`primary`. Don't drop to `secondary` because the list is full — an add that changes weight
|
|
@@ -329,9 +309,9 @@ screen already computes, and it is the copy that goes stale — the band re-coun
|
|
|
329
309
|
and the string does not.
|
|
330
310
|
|
|
331
311
|
It also decides where the count may live. A register whose bands already state their sizes must
|
|
332
|
-
not carry a strip of chips above it repeating the same three numbers; that is one fact twice
|
|
333
|
-
|
|
334
|
-
|
|
312
|
+
not carry a strip of chips above it repeating the same three numbers; that is one fact twice. And
|
|
313
|
+
a count is a reason to PRESS — a control that states how many rows are behind it and does not
|
|
314
|
+
filter to them is stating a fact it refuses to act on.
|
|
335
315
|
|
|
336
316
|
## Period filters for time-constrained data
|
|
337
317
|
|
|
@@ -385,17 +365,16 @@ by WHAT THE SURFACE IS; never a raw `Pressable`.**
|
|
|
385
365
|
|
|
386
366
|
| Surface | Examples | Resting signature | Hover | Base |
|
|
387
367
|
|---|---|---|---|---|
|
|
388
|
-
| Fields (type into / pick from) | `TextInputField`, `Picker`, `Select`, `Combobox`'s input, `NumberInput`, the date/time fields, `DateRangeFilterField`, the inline editors (`InlineSelect`/`InlineDatePicker`) | a BORDER | darkens the border to `HOVER_BORDER` (zinc-
|
|
368
|
+
| Fields (type into / pick from) | `TextInputField`, `Picker`, `Select`, `Combobox`'s input, `NumberInput`, the date/time fields, `DateRangeFilterField`, the inline editors (`InlineSelect`/`InlineDatePicker`) | a BORDER | darkens the border to `HOVER_BORDER` (zinc-500); a borderless in-cell field REVEALS one | `FocusRingPressable` |
|
|
389
369
|
| Actions | `Button`, `IconButton` | a FILL | darkens the fill (own color logic) — never a border, never a wash | built-in |
|
|
390
370
|
| Pills / toggles | `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`, `Tabs`, `Switcher`, `RadioPicker` | a pill SURFACE | washes it (white → zinc-100; pressed zinc-200); selected adds a ring/fill | `chipSurfaceStyle` |
|
|
391
371
|
| Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
|
|
392
372
|
|
|
393
373
|
**ONE signal per species, and it has to be strong enough to carry alone.** `HOVER_BORDER` is
|
|
394
|
-
zinc-500, not 400
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
two is the tell that the first one is too weak for everybody.
|
|
374
|
+
zinc-500, not 400: at 400 the shift is zinc-200 → zinc-400 on a 1px line — measurably a change and
|
|
375
|
+
visually almost nothing, so a control grows a background TINT on top to compensate and the kit ends
|
|
376
|
+
up with two hover languages. Strengthen the shared signal rather than letting one control add a
|
|
377
|
+
second.
|
|
399
378
|
|
|
400
379
|
**A themeable control derives EVERY state from its token — rest, hover, press, disabled.** The
|
|
401
380
|
failure is a control that resolves `colors.primary` correctly at rest and then paints a fixed
|
|
@@ -403,39 +382,35 @@ neighbouring zinc the moment it is touched, so a branded button turns grey under
|
|
|
403
382
|
for an OPAQUE overlay especially: a `backgroundImage` gradient drawn over a themed
|
|
404
383
|
`backgroundColor` hides it entirely while every token in the source reads correct. On web a token
|
|
405
384
|
is a `var()`, so "the same colour, lighter" cannot be computed at the call site — use a translucent
|
|
406
|
-
white/black overlay
|
|
385
|
+
white/black overlay.
|
|
407
386
|
|
|
408
387
|
**A control changes PAINT across its states and never GEOMETRY** — and every property it paints
|
|
409
388
|
has exactly ONE owner. Text that shifts on focus, a border that thickens, a ring drawn twice, a
|
|
410
389
|
tint applied on one path only: each is two pieces of code owning one property and happening to
|
|
411
|
-
agree at rest.
|
|
412
|
-
|
|
413
|
-
shared surface helper, a single frame kept across the view→edit swap — never to nudge the second
|
|
414
|
-
owner into agreeing with the first, which leaves both in place for the next person to re-break.
|
|
390
|
+
agree at rest. The fix is to give the property one owner — a shared surface helper, a single frame
|
|
391
|
+
kept across the view→edit swap — never to nudge the second owner into agreeing with the first.
|
|
415
392
|
|
|
416
393
|
**A control riding a hoverable surface must be DARKER than that surface** — and you have to hover
|
|
417
394
|
BOTH to see whether it is, because reaching the control means crossing its host. Equal washes
|
|
418
395
|
vanish at exactly the moment they are pointed at; a control LIGHTER than its host reads as a hole
|
|
419
|
-
punched in the row rather than as a target sitting on it. Neither failure is visible at rest,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
finding however plausible the value looks.
|
|
396
|
+
punched in the row rather than as a target sitting on it. Neither failure is visible at rest, nor
|
|
397
|
+
hovering the control alone. Take `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` (`control_surface`),
|
|
398
|
+
which are a step past the row's own wash for exactly this reason; a hand-picked grey chosen against
|
|
399
|
+
the resting row is the finding.
|
|
424
400
|
|
|
425
|
-
**A SELECTED control needs a
|
|
426
|
-
|
|
427
|
-
compete
|
|
428
|
-
|
|
401
|
+
**A SELECTED control needs a DOUBLED dark EDGE, not a one-pixel border change.** Among white pills
|
|
402
|
+
a 1px change is the same signal hover already spends, one step stronger, so selection and hover
|
|
403
|
+
compete; `chipSurfaceStyle` doubles `zinc-900` with a `boxShadow` instead, which survives the hover
|
|
404
|
+
wash rather than being replaced by it. A ROW is the other altitude and takes a GROUND — see
|
|
405
|
+
§ Where the accent goes.
|
|
429
406
|
|
|
430
407
|
For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
|
|
431
408
|
open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
|
|
432
|
-
is load-bearing
|
|
433
|
-
|
|
434
|
-
ONLY affordance — a control that then draws nothing in either state and is simply never pressed.
|
|
409
|
+
is load-bearing: a caller's resting `borderColor` is exactly how a frameless variant gets expressed,
|
|
410
|
+
and layered the other way it erases the hover edge that is such a field's ONLY affordance.
|
|
435
411
|
Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
|
|
436
412
|
grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
|
|
437
|
-
greys *and* animates its edge while its siblings only border-hover.
|
|
438
|
-
on a bordered/input control and you've made it.
|
|
413
|
+
greys *and* animates its edge while its siblings only border-hover.
|
|
439
414
|
|
|
440
415
|
## Cards — banded and composable
|
|
441
416
|
|
|
@@ -478,12 +453,11 @@ objects — hairlines between them blur into one run-on list.
|
|
|
478
453
|
|
|
479
454
|
**A summary goes BELOW the controls that produce it.** Its figures are the result of the toolbar —
|
|
480
455
|
they move every time the reader touches a filter — so the layout has to state that order: set the
|
|
481
|
-
query, then read what it returned.
|
|
482
|
-
and the numbers change behind the reader's eye while they are still choosing.
|
|
456
|
+
query, then read what it returned.
|
|
483
457
|
|
|
484
|
-
It is not the page band's subtitle either
|
|
485
|
-
|
|
486
|
-
|
|
458
|
+
It is not the page band's subtitle either. A subtitle describes the SCREEN, which does not change;
|
|
459
|
+
these describe the QUERY, which the toolbar just rewrote. The band carries the title, and the
|
|
460
|
+
summary sits under the controls, immediately above the rows it counts.
|
|
487
461
|
|
|
488
462
|
`SummaryLine` is `Summary`'s `Facts` row on its own — reach for the compound when the set has a
|
|
489
463
|
distribution worth drawing, and for the bare line when it is only a few aggregates. A
|
|
@@ -557,53 +531,42 @@ SECTION** is this same family with the heading rung composed inside it — `Sect
|
|
|
557
531
|
collapsible section composes identically on both surfaces. There is no separate collapsible-section
|
|
558
532
|
component: one heading family and one disclosure across static + collapsible. The disclosure
|
|
559
533
|
header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
|
|
560
|
-
"More", "Options") wrapped around one named section
|
|
561
|
-
leaves the reader no reason to open the chevron.
|
|
534
|
+
"More", "Options") wrapped around one named section.
|
|
562
535
|
|
|
563
536
|
### A control's resting treatment promises what pressing it does
|
|
564
537
|
|
|
565
|
-
Both halves of that fail in practice, and they fail in opposite directions.
|
|
566
|
-
|
|
567
538
|
**A control that looks like text.** A press target whose resting state is a muted caption is
|
|
568
|
-
announced to a screen reader, revealed on hover, and invisible to everyone else
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
whole point. Rendering them the same is not neutrality — it is the promise being made to nobody, or
|
|
580
|
-
to everybody, and both are wrong. When a component takes an optional handler, its resting
|
|
581
|
-
appearance changes with the handler, never only its `accessibilityRole`.
|
|
582
|
-
|
|
583
|
-
The failure is invisible to a test that asserts the role, which is why it survives: the role is
|
|
539
|
+
announced to a screen reader, revealed on hover, and invisible to everyone else. Hover is not an
|
|
540
|
+
affordance; it is a confirmation of one. If pressing it navigates, it wears navigation ink at rest:
|
|
541
|
+
default colour, `medium`, underlined.
|
|
542
|
+
|
|
543
|
+
**Text that looks like a control.** The mirror mistake: the reader presses and nothing happens — or
|
|
544
|
+
something happens that was not what the ink promised. A figure that opens a PEEK is not navigating
|
|
545
|
+
and must not wear the ink of something that leaves; identity marks and captions that go nowhere
|
|
546
|
+
stay `muted`, regular, unruled.
|
|
547
|
+
|
|
548
|
+
When a component takes an optional handler, its resting appearance changes with the handler, never
|
|
549
|
+
only its `accessibilityRole`. The failure is invisible to a test that asserts the role: the role is
|
|
584
550
|
what the element IS, and this rule is about what it LOOKS LIKE.
|
|
585
551
|
|
|
586
552
|
## No dead rows — registers & tables
|
|
587
553
|
|
|
588
554
|
**The identifier leads.** A register's FIRST column is the thing the reader came to find the row
|
|
589
555
|
by — its key, code, or id — because that is what they are scanning against a document, an email,
|
|
590
|
-
or someone on the phone.
|
|
591
|
-
|
|
556
|
+
or someone on the phone. No other column can take its place: a name repeats, a status is shared by
|
|
557
|
+
dozens.
|
|
592
558
|
|
|
593
559
|
Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
|
|
594
560
|
(sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
|
|
595
561
|
read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
|
|
596
562
|
`ActionMenu` (⋯ → `MenuButton` items: destructive last + **confirmed** via `Alert`). The whole
|
|
597
563
|
surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
|
|
598
|
-
|
|
564
|
+
separated by a 4px gap and nothing else.
|
|
599
565
|
|
|
600
566
|
**How MUCH the row reveals decides expand-vs-drawer.** An expanding row is for a few facts the
|
|
601
|
-
reader takes in without losing their place — the rows around it stay visible
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
and they have to remember to collapse it. That is a `Drawer`. The threshold is not a row count, it
|
|
605
|
-
is whether the neighbouring rows survive the expansion; if opening one row means the list is gone,
|
|
606
|
-
open it properly.
|
|
567
|
+
reader takes in without losing their place — the rows around it stay visible. Once the reveal is a
|
|
568
|
+
screenful, it pushes the rest of the register off the fold. That is a `Drawer`. The threshold is
|
|
569
|
+
not a row count, it is whether the neighbouring rows survive the expansion.
|
|
607
570
|
|
|
608
571
|
| `PressableRow` variant | Shape | Use |
|
|
609
572
|
|---|---|---|
|
|
@@ -624,36 +587,33 @@ STATE (the count and Clear — what is picked, and how to un-pick it) from the A
|
|
|
624
587
|
(`children`) with the section boundary getting the most air (20) and each group sitting on the
|
|
625
588
|
action gap (8). Spaced uniformly, four controls read as four equal things, and the emphasis
|
|
626
589
|
ladder between them — muted for a dismiss, `danger` for a destructive act, `primary` for the
|
|
627
|
-
promoted one — stops reading as a ladder
|
|
628
|
-
|
|
629
|
-
WITHIN the acts, where it means something.
|
|
590
|
+
promoted one — stops reading as a ladder. The colours are not the defect when this happens; the
|
|
591
|
+
spacing is. Group first, and the ladder is then read WITHIN the acts, where it means something.
|
|
630
592
|
|
|
631
|
-
The grouping pays a second time at PHONE width
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
do with it) instead of orphaning one verb from the ladder it belongs to. A row of five peers has
|
|
636
|
-
no such seam and breaks wherever it runs out of room.
|
|
593
|
+
The grouping pays a second time at PHONE width. A count plus an escape plus three verbs is ~520px
|
|
594
|
+
of content — wider than a 375 screen — so the bar wraps; because `state` and `acts` are each a
|
|
595
|
+
nowrap row, the ONLY break available is the boundary between them, so the line splits exactly
|
|
596
|
+
where the meaning does instead of orphaning one verb from the ladder it belongs to.
|
|
637
597
|
|
|
638
598
|
**Two exceptions, both real.** A self-contained OVERLAY — a menu, a selector's popover
|
|
639
599
|
(`MenuListItem`, `OptionList`) — has nothing outside itself to align with, so its rows keep
|
|
640
600
|
plain padding; bleeding there would push them past the popover's own edge. And a slot that
|
|
641
|
-
holds another component's PART takes that component's constant, never a hand-picked number
|
|
642
|
-
`AgentRun`'s dot column was 18 while the `Marker` it holds is `NODE` (24), so a 24px dot
|
|
643
|
-
centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
|
|
644
|
-
wrong by the same amount, so it read as internally consistent and nothing flagged it.
|
|
601
|
+
holds another component's PART takes that component's constant, never a hand-picked number.
|
|
645
602
|
|
|
646
603
|
**An indent is legible only when something VISIBLE occupies it**
|
|
647
604
|
— a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
|
|
648
605
|
compensation it forces spreads to every new surface that sits beside it.
|
|
649
606
|
|
|
650
|
-
|
|
651
|
-
shows a zinc-100 highlight; `marked` (a bulk-ticked row)
|
|
607
|
+
NOTHING is drawn between rows — a `Table` spaces them by 4px, and the wash is the only mark a
|
|
608
|
+
row makes. `selected` shows a zinc-100 highlight; `marked` (a bulk-ticked row) shows the SAME
|
|
609
|
+
ground, because what tells a ticked row from an open one is the ticked checkbox.
|
|
652
610
|
|
|
653
611
|
**The COLUMNAR register is `Table`/`TableRow`/`TableCell`**: define `columns` once — the header
|
|
654
|
-
band + every row's cell widths derive from it and can't drift. ONE hairline under the header
|
|
655
|
-
|
|
656
|
-
|
|
612
|
+
band + every row's cell widths derive from it and can't drift. ONE hairline, under the header —
|
|
613
|
+
it divides CHROME from CONTENT, which whitespace cannot say; a rule between two ROWS would divide
|
|
614
|
+
like from like, so the rows below carry none. A sortable column shows its `SortHeader` glyph
|
|
615
|
+
ALWAYS (a neutral up/down glyph when unsorted) so it reads as sortable; pressing cycles none →
|
|
616
|
+
asc → desc → none.
|
|
657
617
|
A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash) —
|
|
658
618
|
so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
|
|
659
619
|
registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
|
|
@@ -666,12 +626,11 @@ Uniform ROW heights hide it completely — the rows measure identical, the conte
|
|
|
666
626
|
not — and the drift is against the row's own centred chrome (ordinal, checkbox) as much as
|
|
667
627
|
against the neighbouring columns.
|
|
668
628
|
|
|
669
|
-
**It takes a height FLOOR to bite
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
another cell's slack that have to hold still.
|
|
629
|
+
**It takes a height FLOOR to bite.** A `TableRow` with `minHeight` above its tallest cell holds
|
|
630
|
+
slack that centring then distributes; without the floor the tallest cell sets the row, every cell
|
|
631
|
+
starts at the top of its own row, and nothing drifts. So the cell that IS tallest may vary freely —
|
|
632
|
+
it just makes the rows ragged instead. It is the cells living inside another cell's slack that have
|
|
633
|
+
to hold still.
|
|
675
634
|
|
|
676
635
|
**"Fixed" means the same every row, NOT one line.** A two-line cell is fine if it is two lines on
|
|
677
636
|
every row. And the cost is real: bounding a cell usually means `numberOfLines={1}` and
|
|
@@ -683,27 +642,24 @@ content off the register entirely.
|
|
|
683
642
|
ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
|
|
684
643
|
a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
|
|
685
644
|
block moves it. A feed is where this actually bites, because a feed is the one row primitive
|
|
686
|
-
whose text is written by the USER. Bound the text
|
|
687
|
-
|
|
688
|
-
EXPANDS to it — a truncation and its source, not a second copy.
|
|
645
|
+
whose text is written by the USER. Bound the text. Where the full value still has to be readable,
|
|
646
|
+
the row EXPANDS to it — a truncation and its source, not a second copy.
|
|
689
647
|
|
|
690
648
|
**Bounding the text is half the fix; the other half is aligning to the LINE, not the block.** A
|
|
691
649
|
clamp turns an 80px drift into a 10px one — it does not remove it, because a two-line label
|
|
692
650
|
still centres its ornaments half a line low while the one-line rows beside it do not. So pin
|
|
693
651
|
every ornament in the band to the first line's centre, from ONE constant the row derives (see
|
|
694
652
|
`FIRST_LINE_CENTRE` in `timeline.tsx`, the reference): the mark, the affordance, and whatever a
|
|
695
|
-
caller passes as trailing content all read it
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
ornament OUTSIDE the row's padding needs its own box, since it does not inherit that offset.
|
|
653
|
+
caller passes as trailing content all read it. Two consequences: a height FLOOR above the content
|
|
654
|
+
is what created the slack being distributed, so deriving the touch target from `padding + line`
|
|
655
|
+
instead removes the drift and the second source of truth in one move; and an ornament OUTSIDE the
|
|
656
|
+
row's padding needs its own box, since it does not inherit that offset.
|
|
700
657
|
|
|
701
658
|
The way it gets in is a SLOT. A component that takes a caller's string or node cannot assume
|
|
702
|
-
anything about it, and every fixture
|
|
703
|
-
|
|
704
|
-
re-open.
|
|
659
|
+
anything about it, and every fixture it was built against is tidy, so the defect is invisible until
|
|
660
|
+
it ships. Bound the slot in the component — not in a prop the next caller can re-open.
|
|
705
661
|
|
|
706
|
-
Two corollaries
|
|
662
|
+
Two corollaries:
|
|
707
663
|
|
|
708
664
|
- **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
|
|
709
665
|
cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
|
|
@@ -721,22 +677,16 @@ freely when something FIXED occupies its top — a meter, a thumbnail — becaus
|
|
|
721
677
|
then still lands near the row's centre, where the chrome already is. `tpl_item_list`'s identity
|
|
722
678
|
cell is the worked example: name on top, the supporting values UNCONDITIONALLY beneath.
|
|
723
679
|
|
|
724
|
-
**A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
`CopyButton` and `CommentsButton` already paint, so a register whose cells use them speaks one
|
|
729
|
-
hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
|
|
730
|
-
cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
|
|
680
|
+
**A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey** —
|
|
681
|
+
they are what `IconButton`, `CopyButton` and `CommentsButton` already paint, so a register whose
|
|
682
|
+
cells use them speaks one hover language. Bleed the wash outward (`marginHorizontal: -8` against
|
|
683
|
+
its own padding) so the cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
|
|
731
684
|
|
|
732
685
|
**A pressable cell's target is the whole CELL, not the text inside it.** Bind the press and the
|
|
733
686
|
hover to the cell box, so the reader aims at a column rather than at a word. Wrapping only the name
|
|
734
|
-
leaves a target whose shape
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
anything. If a cell holds an image and a name and pressing it opens the same thing, the image is
|
|
738
|
-
part of the target too; a cell where only some of the content responds is telling the reader there
|
|
739
|
-
are two things there when there is one.
|
|
687
|
+
leaves a target whose shape changes with the length of the value, and whose wash stops short of the
|
|
688
|
+
cell's edges. If a cell holds an image and a name and pressing it opens the same thing, the image is
|
|
689
|
+
part of the target too.
|
|
740
690
|
|
|
741
691
|
**Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
|
|
742
692
|
when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
|
|
@@ -747,20 +697,17 @@ instrument once its track is long enough to be a rule across the row.
|
|
|
747
697
|
|
|
748
698
|
**The FIXED columns' widths are the flexible one's budget — so audit them by what they CARRY,
|
|
749
699
|
not by what they are called.** With one `flex` column among N fixed, every pixel a fixed column
|
|
750
|
-
takes comes out of the flexible one, silently
|
|
751
|
-
the
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
widest CELL content per column; where a column's width is set by its label rather than its
|
|
756
|
-
values, that difference is the identity column's, not its own.
|
|
700
|
+
takes comes out of the flexible one, silently: the symptom appears somewhere else entirely, as
|
|
701
|
+
truncated content in the column that was supposed to have room. The usual offender is a column
|
|
702
|
+
sized by its HEADER over single-character data. Measure the header row's cells and the widest CELL
|
|
703
|
+
content per column; where a column's width is set by its label rather than its values, that
|
|
704
|
+
difference is the identity column's, not its own.
|
|
757
705
|
|
|
758
706
|
**Space by the RATIO of within-group to between-group, never by absolutes.** Collect the gap
|
|
759
707
|
inside a composed band and the gap between that band and its neighbours, and divide. Near 1 is
|
|
760
|
-
the defect however sensible each number is alone
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
crowds the group to buy separation the page should have provided.
|
|
708
|
+
the defect however sensible each number is alone. Aim for roughly 2 or more, and change the
|
|
709
|
+
BETWEEN gap first: tightening the inside crowds the group to buy separation the page should have
|
|
710
|
+
provided.
|
|
764
711
|
|
|
765
712
|
Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
|
|
766
713
|
`CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
|
|
@@ -772,29 +719,25 @@ only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selecta
|
|
|
772
719
|
disabled checkbox, keeping the gutter aligned). Selection persists across pages, so `count` is the
|
|
773
720
|
running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination`).
|
|
774
721
|
|
|
775
|
-
**A paginated register owes the reader a scroll reset, and it is not the pager's to give.**
|
|
776
|
-
the
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
`tpl_item_list`), never a `setPage(0)` at each call site that also has to remember to scroll.
|
|
722
|
+
**A paginated register owes the reader a scroll reset, and it is not the pager's to give.** An SPA
|
|
723
|
+
swaps the rows under a viewport that does not move, so paging from the bottom lands on the LAST rows
|
|
724
|
+
of the next page with its first ones already scrolled past. Every control that changes WHICH rows
|
|
725
|
+
are on screen owes the same reset — page, tab, search, sort, each filter — so route them all through
|
|
726
|
+
ONE function that moves the window and scrolls (`goToPage` / `resetPaging` in `tpl_item_list`),
|
|
727
|
+
never a `setPage(0)` at each call site that also has to remember to scroll.
|
|
782
728
|
|
|
783
729
|
**ONE pager, ABOVE the rows.** Once the reset returns the viewport to the top on every press, the
|
|
784
730
|
top is where the reader's cursor already is. A second copy under the rows is the same state said
|
|
785
|
-
twice
|
|
731
|
+
twice.
|
|
786
732
|
|
|
787
733
|
**Put it on the `SummaryLine` row, not a row of its own** — summary at `flex: 1`, pager sized to its
|
|
788
734
|
content, the row aligned `flex-start` so the arrows stay on the summary's FIRST line when it wraps.
|
|
789
735
|
Both describe the window the toolbar just set (what matched, and which slice is on screen), so they
|
|
790
|
-
are one line. A pager given its own row is a small cluster against a mostly empty band
|
|
791
|
-
before believing the row is needed; a four-item summary and a full pager leave a quarter of a
|
|
792
|
-
1000px band spare.
|
|
736
|
+
are one line. A pager given its own row is a small cluster against a mostly empty band.
|
|
793
737
|
|
|
794
738
|
Never hand `Pagination` the full width with an empty left slot. It gives its range `flex: 1` so a
|
|
795
739
|
band whose left slot HOLDS something pushes the arrows to the far edge; with nothing there, that
|
|
796
|
-
same rule strands the range hundreds of pixels from the arrows it belongs to
|
|
797
|
-
control reading as two unrelated things on a line.
|
|
740
|
+
same rule strands the range hundreds of pixels from the arrows it belongs to.
|
|
798
741
|
|
|
799
742
|
**The pager's range IS the register's count** (`16–30 of 45`), so pair it with `Table` `counted`,
|
|
800
743
|
never `count` — the latter prints the matched total again a few rows below, and one number in two
|
|
@@ -803,8 +746,7 @@ places is two numbers that can disagree. Row totals beside the pager go the same
|
|
|
803
746
|
|
|
804
747
|
There is deliberately no page index. `Page 2 of 3` beside `16–30 of 45` answers the same question a
|
|
805
748
|
second way and enables no decision the range does not — how much is left is in the total, whether
|
|
806
|
-
this is the end is in the disabled arrow.
|
|
807
|
-
around search and filters is meant to be NARROWED, not paged) earns one label, not two.
|
|
749
|
+
this is the end is in the disabled arrow.
|
|
808
750
|
|
|
809
751
|
Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
|
|
810
752
|
a fixed `width`, so amount/status columns don't jitter.
|
|
@@ -832,9 +774,7 @@ non-interactive by construction. `TableRow` already composes `PressDoor`.
|
|
|
832
774
|
chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
|
|
833
775
|
siblings the eye reads one cluster. Put clearly
|
|
834
776
|
more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
|
|
835
|
-
divider, a weight change or any new element.
|
|
836
|
-
makes every child a peer — applied to a row's right edge, where it is easy to miss because both
|
|
837
|
-
items are small grey glyphs sitting on the same line.
|
|
777
|
+
divider, a weight change or any new element.
|
|
838
778
|
|
|
839
779
|
**"Non-interactive by construction" excludes any SLOT.** A trailing `right` / `actions` / `meta`
|
|
840
780
|
prop takes whatever the caller passes, so a row that wraps one in its own press surface is a
|
|
@@ -851,13 +791,11 @@ so it's still keyboard-reachable. Do that locally — there is deliberately no s
|
|
|
851
791
|
primitive.
|
|
852
792
|
|
|
853
793
|
**A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
|
|
854
|
-
The surface carries no blanket `userSelect: "none"` —
|
|
855
|
-
|
|
856
|
-
furniture wasn't already buying for itself (`Badge`, `Avatar`, `Button` each opt out). Both press
|
|
794
|
+
The surface carries no blanket `userSelect: "none"` — text a reader drags out of a table is the one
|
|
795
|
+
way anybody gets a value out of it (`Badge`, `Avatar`, `Button` each opt out). Both press
|
|
857
796
|
surfaces consult `pressSelectedText()` instead, so a release that ENDED a selection doesn't also
|
|
858
797
|
open the record and throw that selection away. Only a real, non-empty selection suppresses: a
|
|
859
|
-
swallowed ordinary click would leave the row impossible to open
|
|
860
|
-
this fixes.
|
|
798
|
+
swallowed ordinary click would leave the row impossible to open.
|
|
861
799
|
|
|
862
800
|
Selection covers every value at once, so reach for **`CopyButton`** only where one value is copied
|
|
863
801
|
often enough to earn a control (a phone number a rep dials, a reference quoted into a message) —
|
|
@@ -884,34 +822,29 @@ Identity is the handful of facts someone would say aloud to name this record (it
|
|
|
884
822
|
counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
|
|
885
823
|
reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
|
|
886
824
|
labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
|
|
887
|
-
that leaves a reader who cannot tell whose record they are in.
|
|
888
|
-
neither is caught by any check that reads the rendered screen. **When a leading block buries the
|
|
825
|
+
that leaves a reader who cannot tell whose record they are in. **When a leading block buries the
|
|
889
826
|
answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
|
|
890
827
|
fact can still be the one that names the thing.
|
|
891
828
|
|
|
892
829
|
"The chrome already carries identity, so the body need not lead with it" is a real argument and a
|
|
893
830
|
falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
|
|
894
|
-
number and its totals carries half an identity; the counterparty is missing
|
|
895
|
-
makes the claim feel true while the reader still cannot name what they are looking at.
|
|
831
|
+
number and its totals carries half an identity; the counterparty is missing.
|
|
896
832
|
|
|
897
833
|
**All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
|
|
898
834
|
stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
|
|
899
835
|
never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
|
|
900
|
-
record page they are already in it
|
|
901
|
-
|
|
902
|
-
pick the rule — for a ledger it is ONE at a time, because two open details push the next row off
|
|
903
|
-
screen and a register that cannot be scanned has stopped being one. The boundary is the detail's
|
|
836
|
+
record page they are already in it. `expanded` is CONTROLLED, so pick the rule — for a ledger it is
|
|
837
|
+
ONE at a time, because two open details push the next row off screen. The boundary is the detail's
|
|
904
838
|
weight, not its field count: read or light-edit (independent inline commits) expands, a real form
|
|
905
839
|
with a submit and cross-field validation still earns its own surface.
|
|
906
840
|
|
|
907
841
|
The expansion carries **EVERY** field, including the ones already in columns. Column cells are
|
|
908
842
|
read-only, and `table_fit` tier 2 drops droppable columns on the explicit promise that a door
|
|
909
|
-
reaches the dropped values — the expansion IS that door, so anything it omits is unreachable
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
width at any measure.
|
|
843
|
+
reaches the dropped values — the expansion IS that door, so anything it omits is unreachable.
|
|
844
|
+
`Table` does not expose its fit result, so this cannot be conditional. This is also the answer to
|
|
845
|
+
"just add more columns": the fit budget is the MEASURED container, so past it the register drops
|
|
846
|
+
columns silently instead of overflowing. And free text (a note) has no column width at any
|
|
847
|
+
measure.
|
|
915
848
|
|
|
916
849
|
## Two work shapes, no side panels
|
|
917
850
|
|
|
@@ -953,12 +886,11 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
953
886
|
content is TEXT: a line is ~20, so `minHeight` governs and the padding never binds — which is
|
|
954
887
|
why a surface can pick any inset it likes and nothing on a resting screen ever contradicts it.
|
|
955
888
|
The moment something with a FIXED height goes in — an avatar, a swatch, a thumbnail — that
|
|
956
|
-
inset decides the control's whole height
|
|
957
|
-
(4, 6, 8) rendered one member chip at 46, 50 and 54. So: a fixed-height child is sized against
|
|
889
|
+
inset decides the control's whole height. So: a fixed-height child is sized against
|
|
958
890
|
the band's budget, never against how it looks alone, and a surface that needs more room than
|
|
959
891
|
the budget is not a control. The budget is 28 because that is what `InlineButton` and a `md`
|
|
960
|
-
`IconButton`
|
|
961
|
-
by what KIND of thing they are
|
|
892
|
+
`IconButton` use inside a field — a control's children must not differ in height
|
|
893
|
+
by what KIND of thing they are.
|
|
962
894
|
|
|
963
895
|
- **`SearchInput`** is THE search box — a white `TextInputField` preset (the shared
|
|
964
896
|
`CONTROL_RADIUS` like every other input, a thin border, the leading search glyph + a clear ✕).
|
|
@@ -996,8 +928,8 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
996
928
|
- **A form's COMMIT sits with its fields, never in the footer.** The button submitting a set of
|
|
997
929
|
inputs goes directly beneath the last one it commits, `alignSelf="flex-start"` — the same left
|
|
998
930
|
edge the user just filled (data_entry.md's form-action alignment law, which holds inside an
|
|
999
|
-
overlay too). A footer commit
|
|
1000
|
-
|
|
931
|
+
overlay too). A footer commit reads as belonging to the dialog rather than the fields.
|
|
932
|
+
A **`Reset` travels with the `Save` it
|
|
1001
933
|
undoes** — same draft, same row, secondary to the commit's left; splitting the pair across two
|
|
1002
934
|
surfaces breaks one decision in half. A dialog whose only actions are a commit and its reset has
|
|
1003
935
|
**no footer at all**.
|
|
@@ -1007,11 +939,10 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
1007
939
|
at the right edge, secondary to its left. A confirm dialog (no fields) is all surface: its
|
|
1008
940
|
buttons ARE the dialog, and they belong in the footer.
|
|
1009
941
|
- **A full-page editor is not a dialog.** Where the body SCROLLS and one commit governs the whole
|
|
1010
|
-
page, that commit stays pinned at the page's foot:
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
clusters never compete for the same press.
|
|
942
|
+
page, that commit stays pinned at the page's foot: inlining it makes a reader scroll a long
|
|
943
|
+
document to the end to save an edit made at the top. What the page's title row carries instead
|
|
944
|
+
is the actions on the OBJECT — hand it to the assistant, the overflow — so the pinned bar holds
|
|
945
|
+
the commit alone.
|
|
1015
946
|
- **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
|
|
1016
947
|
- **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
|
|
1017
948
|
Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
|
|
@@ -1032,8 +963,7 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
1032
963
|
carries a ground (red-100) exactly where `secondary` carries zinc-100, so every act in the row
|
|
1033
964
|
keeps ONE shape and only the ink moves; the ladder is then legible as a ladder. Drop a
|
|
1034
965
|
groundless `danger-secondary` in there and the destructive verb is the one control with no box
|
|
1035
|
-
— a shape difference, which a reader parses before colour
|
|
1036
|
-
row stops looking designed (this is what "four buttons with different shapes" is, every time).
|
|
966
|
+
— a shape difference, which a reader parses before colour.
|
|
1037
967
|
Among TEXT — a row's trailing verb, a `Callout`'s action, a dialog's tertiary — use
|
|
1038
968
|
`danger-secondary`, because there a filled box is the thing that looks pasted on. A
|
|
1039
969
|
fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
|
|
@@ -1045,21 +975,16 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
1045
975
|
`InlineButton` (28px, filled, on a field's own surface).
|
|
1046
976
|
|
|
1047
977
|
**AN ACT CARRIES A CONTROL; UNDERLINED TEXT REVEALS.** `Link`/`TextLink` are the underlined
|
|
1048
|
-
pair; everything that MUTATES is a `Button` or an `InlineButton`.
|
|
1049
|
-
|
|
1050
|
-
stopped answering the one question a reader has — does this take me away, or does it do
|
|
1051
|
-
something here. It also competed with the `Button` colour ladder, since `muted` already IS the
|
|
1052
|
-
quiet rung.
|
|
978
|
+
pair; everything that MUTATES is a `Button` or an `InlineButton`. There is no surface-less
|
|
979
|
+
third rung — `muted` already IS the quiet rung of the `Button` colour ladder.
|
|
1053
980
|
|
|
1054
981
|
**The ONE exception, and it is narrow: an IN-PROSE DISCLOSURE.** A show/hide for adjacent
|
|
1055
982
|
content — a transcript under its player, a long value under its summary — has nowhere else to
|
|
1056
|
-
go. It cannot move to chrome, because it belongs to the thing above it
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
a
|
|
1060
|
-
|
|
1061
|
-
full-width header with its own chevron, and nesting one inside a row that already has a chevron
|
|
1062
|
-
is list furniture borrowed for a one-off verb.
|
|
983
|
+
go. It cannot move to chrome, because it belongs to the thing above it. `Button color="muted"`
|
|
984
|
+
measures transparent, borderless and undecorated AT REST, with its wash arriving only on hover:
|
|
985
|
+
that is a hover-only affordance, banned above. `Accordion` overcorrects — it is a LIST-ROW
|
|
986
|
+
primitive, a full-width header with its own chevron, and nesting one inside a row that already
|
|
987
|
+
has a chevron is list furniture borrowed for a one-off verb.
|
|
1063
988
|
|
|
1064
989
|
So underline is permitted here, and the INK keeps it unambiguous:
|
|
1065
990
|
|
|
@@ -1068,13 +993,11 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
1068
993
|
| blue + `role="link"` (`Link`) | leaves this surface |
|
|
1069
994
|
| muted underline in a caller's `Pressable` (`TextLink`, no `href`) | reveals more of it, in place |
|
|
1070
995
|
|
|
1071
|
-
Both say "show me more"; only one takes you away
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
the
|
|
1076
|
-
"success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
|
|
1077
|
-
(colored `Text`), not the buttons.
|
|
996
|
+
Both say "show me more"; only one takes you away. It stays scoped to REVEAL: a press that
|
|
997
|
+
saves, sends, deletes or opens an overlay carries a control surface, always. Never a `muted`
|
|
998
|
+
text action that MUTATES: it mutes the one thing carrying the act. No "success"/green button.
|
|
999
|
+
Decision UIs put positive/negative color on the STATUS (dot) and verdict (colored `Text`), not
|
|
1000
|
+
the buttons.
|
|
1078
1001
|
- **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
|
|
1079
1002
|
rides its section's HEADING row at the right edge — see § The add-placement law for why, and
|
|
1080
1003
|
for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
|
|
@@ -1125,10 +1048,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1125
1048
|
should be is decided per table — see data_entry.md §The editability affordance.
|
|
1126
1049
|
- **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
|
|
1127
1050
|
this reference, call this number, open this record: the act names the value, so it travels
|
|
1128
|
-
with what it acts on
|
|
1129
|
-
|
|
1130
|
-
column is paid by every row for the benefit of one — it was declared on the TABLE, so a
|
|
1131
|
-
single `Copy` made every value cell stop short of an edge nothing occupied. EVERY `Inline*`
|
|
1051
|
+
with what it acts on. `DetailRow` has NO trailing slot, because a reserved
|
|
1052
|
+
column is paid by every row for the benefit of one. EVERY `Inline*`
|
|
1132
1053
|
editor takes `actions`, popover-backed ones included — a field exposes its outer box
|
|
1133
1054
|
separately from the control inside it, so a select's list and a date's calendar still open at
|
|
1134
1055
|
the field's own width. Anything that is not a verb — a status `Badge`, a unit — composes into
|
|
@@ -1139,22 +1060,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1139
1060
|
editor promises never to do.
|
|
1140
1061
|
- **What is about the WHOLE record is a PANEL, not a section.** A section takes a position in a
|
|
1141
1062
|
top-to-bottom reading order, which is a claim that it belongs at that point in the read. A
|
|
1142
|
-
record's DISCUSSION does not: a note is written about anything on the page
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
discussion is docked precisely because a thing "parked at one position is unreachable from
|
|
1152
|
-
every other". Routing the sections makes that true of every one of them. It is tempting on a
|
|
1153
|
-
long record and it does not survive contact: each fix it needs rebuilds the whole-record view
|
|
1154
|
-
in miniature — a dot to say which hidden section just changed, a page-band action to
|
|
1155
|
-
re-surface an affordance some section had swallowed, an "everything" route that IS this page.
|
|
1156
|
-
It also HIDES what it does not carry: a whole-record drop target advertised only inside the
|
|
1157
|
-
files section does not exist from the eight destinations that are not it. And it charges
|
|
1063
|
+
record's DISCUSSION does not: a note is written about anything on the page, so parked at one
|
|
1064
|
+
position it is unreachable from every other. Dock it in the page's right gutter (the one the
|
|
1065
|
+
outline rail's balance already reserved), pinned, scrolling inside itself so a long thread never
|
|
1066
|
+
lengthens the record. Give it no rail entry — it is not a place you navigate to. Narrow, it
|
|
1067
|
+
collapses onto the same bar the sections use, on the RIGHT.
|
|
1068
|
+
- **A record is ONE PAGE. A section is a place you SCROLL to, never a route you swap to.**
|
|
1069
|
+
Routing the sections makes every one of them "parked at one position and unreachable from every
|
|
1070
|
+
other". It also HIDES what it does not carry: a whole-record drop target advertised only inside
|
|
1071
|
+
the files section does not exist from the eight destinations that are not it. And it charges
|
|
1158
1072
|
permanently — browser find reaches only the open section, two sections can never be read
|
|
1159
1073
|
together, and every cross-section check costs a click each way. A long record is a DENSITY
|
|
1160
1074
|
problem, and density is cuttable (a choice that took four rows, a group that restated the
|
|
@@ -1169,8 +1083,7 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1169
1083
|
`Transfer point` / `Destination` looks like three fields and is actually one route — it cannot
|
|
1170
1084
|
hold a second transfer, and three identical rows say nothing about which comes first. The same
|
|
1171
1085
|
goes for an approval chain, a set of legs, a list of signatories. Rendered as a `Sequence` it
|
|
1172
|
-
gains the one thing the grid could never give it — visible ORDER
|
|
1173
|
-
fields around it, because it is no longer pretending to be one.
|
|
1086
|
+
gains the one thing the grid could never give it — visible ORDER.
|
|
1174
1087
|
Nothing about spacing or disclosure fixes a section holding the wrong SHAPE, and a section that
|
|
1175
1088
|
looks "unattended" is usually this rather than a density problem.
|
|
1176
1089
|
- **A VERB GOES WHERE ITS EFFECT LANDS.** One law places every action on a record surface, and
|
|
@@ -1185,19 +1098,16 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1185
1098
|
| the record's EXISTENCE | the `DangerZone`, last on the page |
|
|
1186
1099
|
|
|
1187
1100
|
The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
|
|
1188
|
-
says why
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
verb. **An add is `primary` wherever it renders** — on that heading row, or inside an
|
|
1192
|
-
`EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
|
|
1101
|
+
says why. The heading row therefore carries identity + meta + view controls + the section's ADD,
|
|
1102
|
+
and no other content verb. **An add is `primary` wherever it renders** — on that heading row,
|
|
1103
|
+
or inside an `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
|
|
1193
1104
|
is the act its section offers, so it carries that section's weight; a lone `secondary` button
|
|
1194
1105
|
reads as though the real action were somewhere else. The rule that matters is that ONE VERB
|
|
1195
1106
|
KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
|
|
1196
1107
|
A record surface therefore carries several primaries, one per section, and that is correct:
|
|
1197
1108
|
"one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
|
|
1198
|
-
its own single act. The
|
|
1199
|
-
|
|
1200
|
-
never a quieter button.
|
|
1109
|
+
its own single act. The more sections offer an act, the less any one of them stands out — so the
|
|
1110
|
+
answer to a loud page is FEWER SECTIONS WITH ACTS, never a quieter button.
|
|
1201
1111
|
- **A DENSE section is compacted by a SUMMARY above it, by ROW HEIGHT, and by grouping fields
|
|
1202
1112
|
that are ONE ENTITY — never by a two-column layout.** The evidence is unusually clear here, so
|
|
1203
1113
|
do not re-derive it: Baymard finds an overall two-column form layout "generally problematic and
|
|
@@ -1212,14 +1122,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1212
1122
|
name its parts by adding a sub-label line no other row has — which drops its controls off the
|
|
1213
1123
|
baseline every other row shares — and naming them with placeholders loses the names the moment
|
|
1214
1124
|
the row is filled. In a label-left grid, ONE FIELD PER ROW is not a failure to compact; it is
|
|
1215
|
-
the only shape that keeps the column a reader scans.
|
|
1216
|
-
re-runs them.
|
|
1125
|
+
the only shape that keeps the column a reader scans.
|
|
1217
1126
|
**A spread `DetailRow` gives its VALUE the squeeze, never its label.** The two share one flex
|
|
1218
1127
|
row, so whichever of them cannot shrink forces the other to. A flex child defaults to
|
|
1219
1128
|
`min-width: auto`, which floors the value at its longest word — and the label, having no floor
|
|
1220
|
-
of its own, absorbs everything left
|
|
1221
|
-
|
|
1222
|
-
|
|
1129
|
+
of its own, absorbs everything left, collapsing to a one-character-wide column many times taller
|
|
1130
|
+
than the row. The `minWidth: 0` on the value is what stops it. **The probe generalises past this
|
|
1131
|
+
component**:
|
|
1223
1132
|
wherever a fixed-ish label shares a row with a `flex: 1` value, measure the LABEL's width and
|
|
1224
1133
|
height across rows whose values differ in length — a label whose box changes shape from row to
|
|
1225
1134
|
row is the signature, and it is invisible at any width where every value happens to fit.
|
|
@@ -1227,9 +1136,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1227
1136
|
required field, while a record page is read-mostly.
|
|
1228
1137
|
A SUMMARY belongs to the RECORD, not to a section. The highlights-panel precedent (a strip of
|
|
1229
1138
|
key facts above the detail) is a PAGE-level device, where what it summarises is a scroll or a
|
|
1230
|
-
tab away; repeated per section it sits inches above the very rows it repeats
|
|
1231
|
-
|
|
1232
|
-
data. Put the few facts someone would ask for by phone in the record's own header, once.
|
|
1139
|
+
tab away; repeated per section it sits inches above the very rows it repeats. Put the few facts
|
|
1140
|
+
someone would ask for by phone in the record's own header, once.
|
|
1233
1141
|
A read-only view behind an Edit button is the last resort: it compacts by DROPPING empty
|
|
1234
1142
|
fields — and an empty row is information, the difference between "this record has no vehicle"
|
|
1235
1143
|
and "this section has no vehicle field" — and it costs a MODE, against the law that the surface
|
|
@@ -1237,9 +1145,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1237
1145
|
- **A REFERENCE to another record is a FIELD, not a section and not a card.** The kit already
|
|
1238
1146
|
draws every other reference as a token — a person is a `MemberChip`, a file a `FileBadge`, a
|
|
1239
1147
|
select option an `OptionBadge` — and a record is no different. It renders as a VALUE wearing
|
|
1240
|
-
the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius)
|
|
1241
|
-
|
|
1242
|
-
it. Put BOTH the name and its code INSIDE it — one entity reads as one object, and a muted
|
|
1148
|
+
the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius).
|
|
1149
|
+
Put BOTH the name and its code INSIDE it — one entity reads as one object, and a muted
|
|
1243
1150
|
code beside a link reads as an orphan.
|
|
1244
1151
|
**ONE act on the surface, the rest one layer in.** Pressing the field PEEKS the facts — free,
|
|
1245
1152
|
so it takes the whole box as its target — and every verb lives INSIDE the peek, placed by
|
|
@@ -1250,21 +1157,18 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1250
1157
|
Change is `secondary` and NOT `danger` — a correction, not a destruction — Edit takes the one
|
|
1251
1158
|
filled-dark rung and hands it to Save, Open stays `secondary` and never prioritized. None of
|
|
1252
1159
|
them sits on the field. After Change, hand the picker focus; after Clear, never — the two
|
|
1253
|
-
verbs land in the same place meaning opposite things. An interior verb
|
|
1254
|
-
(press the box → a summary; press 8px right → navigate),
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
ABOUT the value — leaving is the rare move on a record page. Without it the field has its plain
|
|
1258
|
-
anatomy back: the press target is the box, the focus ring rings the field.
|
|
1160
|
+
verbs land in the same place meaning opposite things. An interior verb puts two destinations
|
|
1161
|
+
behind one object (press the box → a summary; press 8px right → navigate), and is neither common
|
|
1162
|
+
nor an act ABOUT the value — leaving is the rare move on a record page. The field has its plain
|
|
1163
|
+
anatomy: the press target is the box, the focus ring rings the field.
|
|
1259
1164
|
Detach earns its depth twice — destructive and rare, and it ANSWERS the question the peek asks
|
|
1260
1165
|
("is this the right one?"), so the check and the correction are one gesture. A detach link
|
|
1261
1166
|
repeated down a column of references is noise for an act most readers never perform.
|
|
1262
1167
|
**Open is OPTIONAL, and Edit is why.** A reference whose facts can be corrected in the peek has
|
|
1263
|
-
no errand to send the reader on
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
inline editor, because there blur and dismiss are one gesture.
|
|
1168
|
+
no errand to send the reader on. Edit opens a DRAFT in the same grid — nothing commits until
|
|
1169
|
+
Save, which sends only what CHANGED — and the open draft PINS the popover so outside-press and
|
|
1170
|
+
Escape cannot bin the typing. That pinning is the whole licence: a peek may hold a draft, and may
|
|
1171
|
+
never hold a self-persisting inline editor, because there blur and dismiss are one gesture.
|
|
1268
1172
|
**Never promote it to a Section.** A section claims a distinct AREA of the record and buys an
|
|
1269
1173
|
outline-rail entry; one pointer at one other row never earns that. What tempts the promotion is
|
|
1270
1174
|
the machinery around it — and each piece is just part of the field: the search is its EMPTY
|
|
@@ -1299,9 +1203,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1299
1203
|
- **A PERSON token peeks the same way a record reference does — but only where nothing else
|
|
1300
1204
|
on the line presses.** `MemberPeek` makes a `MemberChip` a door onto `MemberProfileCard`
|
|
1301
1205
|
(a `xl` avatar, the sign-in identity, role, teams), and it is the same drill-down grammar:
|
|
1302
|
-
identity on the surface, the facts one layer in. The constraint is where it may live
|
|
1303
|
-
|
|
1304
|
-
something already pressable puts two destinations behind one object.** Two surfaces fail it
|
|
1206
|
+
identity on the surface, the facts one layer in. The constraint is where it may live: **a token
|
|
1207
|
+
inside something already pressable puts two destinations behind one object.** Two surfaces fail it
|
|
1305
1208
|
and both keep the plain chip — a PICKER OPTION (a card over the open menu, fired at the one
|
|
1306
1209
|
moment the reader is choosing rather than browsing) and a ROW THAT NAVIGATES (press the row,
|
|
1307
1210
|
open the record; press four pixels left, open a card). What is left is where a peek is
|
|
@@ -1311,10 +1214,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1311
1214
|
the drill-down.
|
|
1312
1215
|
**Judge the CONTAINER, not how inert it looks.** A grouped register's band header names a
|
|
1313
1216
|
person, carries no visible control, and is a button — it collapses the group — so a token
|
|
1314
|
-
inside it nests a button in a button and splits one gesture in two.
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
at all rather than the least-bad host.
|
|
1217
|
+
inside it nests a button in a button and splits one gesture in two. A screen built entirely of
|
|
1218
|
+
editors, pickers and navigating rows has NOWHERE a peek belongs, and the right move there is no
|
|
1219
|
+
peek at all rather than the least-bad host.
|
|
1318
1220
|
**A profile's rows are conditional, and absent is not empty**: a field nobody supplied has
|
|
1319
1221
|
no row, while a field supplied as EMPTY keeps its row and says so. Collapsing the two turns
|
|
1320
1222
|
"we were not told" into a confident claim about a colleague — the same distinction a
|
|
@@ -1328,8 +1230,7 @@ checkbox, a switch, a chip. The line is not "is it pressable" (nearly everything
|
|
|
1328
1230
|
"does pressing it DO something, or does it set a value / open the thing you are already
|
|
1329
1231
|
looking at".
|
|
1330
1232
|
|
|
1331
|
-
Drawn tightly because a signal on half the screen is not a signal.
|
|
1332
|
-
only "this is a web page"; reserved, it marks the few things that act. What carries "you can
|
|
1233
|
+
Drawn tightly because a signal on half the screen is not a signal. What carries "you can
|
|
1333
1234
|
press this" elsewhere is the hover treatment the surface already owns — a row's wash, a
|
|
1334
1235
|
field's border, a chip's fill — which is what a native list or menu uses too, where the
|
|
1335
1236
|
cursor stays an arrow throughout.
|
|
@@ -1355,52 +1256,45 @@ ask for one.
|
|
|
1355
1256
|
**WHAT a line separates is the whole test — not how big the break feels.** A rule between two rows
|
|
1356
1257
|
divides LIKE FROM LIKE, and the space between them already says that. The band's rule divides
|
|
1357
1258
|
CHROME from CONTENT — a row of labels from the records they name — and no amount of whitespace
|
|
1358
|
-
states that
|
|
1359
|
-
|
|
1360
|
-
a first row rather than as the header of the rest.
|
|
1259
|
+
states that. Without a rule beneath them the labels read as a first row rather than as the header
|
|
1260
|
+
of the rest.
|
|
1361
1261
|
|
|
1362
1262
|
Drawing both makes them compete, and a grid of equal lines is most of what "it looks like a
|
|
1363
1263
|
spreadsheet" means.
|
|
1364
1264
|
|
|
1365
1265
|
**The hover wash does the work the rule did**, and it already has the shape: `PressableRow` bleeds
|
|
1366
1266
|
its ground past the content and rounds it, so a register reads as a stack of cards with no card
|
|
1367
|
-
component involved.
|
|
1368
|
-
|
|
1369
|
-
to read as a surface rather than a stripe, which is what the 72 buys.
|
|
1267
|
+
component involved. The row has to be tall enough for that wash to read as a surface rather than a
|
|
1268
|
+
stripe, which is what the 72 buys.
|
|
1370
1269
|
|
|
1371
1270
|
**Density is a number, never a mode.** A surface that genuinely needs more rows on screen sets
|
|
1372
|
-
`minHeight` on the row. What no caller can ask for is the rules back —
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
rhythms used to express already lived at the component boundary; offering it again as a prop was a
|
|
1376
|
-
third way to say the same thing, and the way that let a register contradict this rule.
|
|
1271
|
+
`minHeight` on the row. What no caller can ask for is the rules back — a table of VALUES rather
|
|
1272
|
+
than of objects is a different component: `Matrix` for a numeric grid (a statement, a pivot, a
|
|
1273
|
+
rollup), `DataGrid` for a compact one.
|
|
1377
1274
|
|
|
1378
1275
|
## A register that TRIAGES — `TableGroup`
|
|
1379
1276
|
|
|
1380
1277
|
A flat register sorted by a column hands the reader every row at equal weight and leaves the
|
|
1381
|
-
deciding to them
|
|
1382
|
-
|
|
1278
|
+
deciding to them. `TableGroup` moves that decision into the screen — a titled band of rows with a
|
|
1279
|
+
count.
|
|
1383
1280
|
|
|
1384
1281
|
**Flat is the default; grouping is something the reader turns ON.** A register opens as one
|
|
1385
1282
|
continuous list, and the control that splits it into bands lives in the filter band beside the
|
|
1386
1283
|
other view controls — an explicit "group by" the reader operates, defaulting to none. Authored
|
|
1387
1284
|
bands fail three ways at once: the value they group on is unsortable, it is unfilterable, and it
|
|
1388
1285
|
vanishes the moment the reader wants a different cut — so the screen holds an opinion the reader
|
|
1389
|
-
cannot inspect, disagree with, or put down.
|
|
1390
|
-
the screen, including the many times the triage it encodes is not the question they came with.
|
|
1286
|
+
cannot inspect, disagree with, or put down.
|
|
1391
1287
|
|
|
1392
1288
|
**The status COLUMN is the floor.** Whatever the register can group on also exists as a column the
|
|
1393
1289
|
reader can see, sort and filter — the column is the fact, the grouping is one way of looking at it.
|
|
1394
1290
|
Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
|
|
1395
|
-
picked. Everything below
|
|
1396
|
-
longer describes the state the screen starts in.
|
|
1291
|
+
picked. Everything below governs how the bands look and behave once they are on.
|
|
1397
1292
|
|
|
1398
1293
|
**A value's filter renders the value the same way its cell does.** If the status cell is a
|
|
1399
1294
|
`Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
|
|
1400
|
-
colours, same shapes.
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
custom-rendered value, not just status.
|
|
1295
|
+
colours, same shapes. Re-rendering it as plain text makes the reader translate between two
|
|
1296
|
+
presentations of one field, and the two drift the first time either side gains a value. This holds
|
|
1297
|
+
for every custom-rendered value, not just status.
|
|
1404
1298
|
|
|
1405
1299
|
**Group by what implies a different ACTION, never by a category the reader can already see.**
|
|
1406
1300
|
Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
|
|
@@ -1428,64 +1322,55 @@ column is a sort wearing chrome: it spends a band per value to repeat the cell b
|
|
|
1428
1322
|
the archived ones — and the tempting shortcut is to let the band list double as the vocabulary,
|
|
1429
1323
|
treating "matches no band" as "unrecognised". It breaks in both directions at once. The values
|
|
1430
1324
|
you deliberately left out now match nothing, so they resurface under whatever heading catches
|
|
1431
|
-
the remainder — *Unclassified*, *Other
|
|
1432
|
-
and states something false about them. Fix that by treating "known" as "shown" instead, and a
|
|
1325
|
+
the remainder — *Unclassified*, *Other*. Fix that by treating "known" as "shown" instead, and a
|
|
1433
1326
|
value added to the field later silently disappears from the one screen that would have shown it.
|
|
1434
1327
|
Keep a set of values the screen can classify and a list of bands it renders: a value in the
|
|
1435
1328
|
first but not the second is omitted on purpose, and a value in neither gets a visible band,
|
|
1436
1329
|
because a register that quietly files what it does not understand is how a stale row stays
|
|
1437
1330
|
invisible. Both lists are ALLOWLISTS — `else` is where the second one goes wrong.
|
|
1438
1331
|
|
|
1439
|
-
Banding also changes what the COLUMNS have to carry
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1332
|
+
Banding also changes what the COLUMNS have to carry: a column that exists to signal presence ("is
|
|
1333
|
+
there a next step?") is now stated by the band, so it is left carrying only its text — and it will
|
|
1334
|
+
be empty in every band but one. That is not necessarily wrong, but it is a new duplication to look
|
|
1335
|
+
at.
|
|
1443
1336
|
|
|
1444
1337
|
## The register's own craft — what "it looks like a spreadsheet" actually is
|
|
1445
1338
|
|
|
1446
1339
|
Three defects produce that report, none of them about colour, and all three survive a design
|
|
1447
|
-
audit.
|
|
1448
|
-
that one is settled above — a register has one line, and it is the band.)
|
|
1340
|
+
audit.
|
|
1449
1341
|
|
|
1450
1342
|
**A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
|
|
1451
1343
|
it heads in size and separating from them by weight and ink. It is not an eyebrow: an eyebrow is
|
|
1452
1344
|
`xs`, calibrated to sit quietly beside a value it captions, and a column header names a whole
|
|
1453
1345
|
column of them. Dropped to `xs` and lightened past `muted` it stops being legible and starts
|
|
1454
|
-
reading as a caption that lost its value
|
|
1455
|
-
|
|
1346
|
+
reading as a caption that lost its value. The active SORTED column steps up in ink, never in
|
|
1347
|
+
weight; semibold belongs to the heading ladder.
|
|
1456
1348
|
|
|
1457
1349
|
**A fixed column `width` is sized for the LONGEST locale, not the one you typed it in.** Every
|
|
1458
1350
|
header is a translated string, and the same label runs half again as long in Vietnamese as in
|
|
1459
1351
|
English — so a width measured against the source truncates the header in the language the app
|
|
1460
|
-
actually ships in
|
|
1461
|
-
|
|
1462
|
-
`flex` and let it take what it needs.
|
|
1352
|
+
actually ships in. It fails silently in review because the reviewer reads the source locale. Size
|
|
1353
|
+
against the longest translation, or give the column `flex` and let it take what it needs.
|
|
1463
1354
|
|
|
1464
1355
|
**Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
|
|
1465
|
-
the #1 templated tell
|
|
1466
|
-
|
|
1467
|
-
without replacing the separation is what left the header looking adrift — the ink step above is
|
|
1468
|
-
the replacement, and the two changes only work together.
|
|
1356
|
+
the #1 templated tell. Removing the case without replacing the separation leaves the header
|
|
1357
|
+
looking adrift — the ink step above is the replacement, and the two changes only work together.
|
|
1469
1358
|
|
|
1470
1359
|
**A LOOKUP KEY IS NOT A COLUMN — and this is where "cramped" usually comes from.** Ask of each
|
|
1471
1360
|
column whether its value is COMPARED down the page or MATCHED one at a time. A reference,
|
|
1472
1361
|
identifier or code that a reader arrives already holding — quoted on the phone, printed on a
|
|
1473
1362
|
document, pasted from an email — is matched, and the question it answers is "which record is this
|
|
1474
1363
|
one", which the search field answers exactly and without scanning. A column answers it by spending
|
|
1475
|
-
its width on every row forever, and takes that width from the columns that ARE compared.
|
|
1476
|
-
on
|
|
1477
|
-
carrying the facts a reader scans lost 224px and 84px of real values: cutting the references
|
|
1478
|
-
halved the truncation and returned the width to where the eye actually goes. Put them in the
|
|
1479
|
-
search haystack, on the record, and in the row's expanded detail if they must be visible — never
|
|
1364
|
+
its width on every row forever, and takes that width from the columns that ARE compared. Put them
|
|
1365
|
+
in the search haystack, on the record, and in the row's expanded detail if they must be visible — never
|
|
1480
1366
|
in a column, unless the value is genuinely compared (a sequence a reader reads for gaps, a code
|
|
1481
1367
|
whose PREFIX groups the rows).
|
|
1482
1368
|
|
|
1483
1369
|
**`Table` does not scroll horizontally, and that is the feature.** When the columns do not fit, it
|
|
1484
1370
|
sheds by `priority` and then stacks — it never offers a pan. A horizontal scroller is a WIDTH fix
|
|
1485
1371
|
for a COUNT problem: it makes room for one more column without ever asking whether the column
|
|
1486
|
-
earns a row's width, and the columns it hides off-screen are hidden from the author too
|
|
1487
|
-
|
|
1488
|
-
the reader compare", asked at the only time it gets answered. A dataset that genuinely wants pan —
|
|
1372
|
+
earns a row's width, and the columns it hides off-screen are hidden from the author too. The
|
|
1373
|
+
narrowing IS the question "which of these does the reader compare". A dataset that genuinely wants pan —
|
|
1489
1374
|
every column compared against its neighbours — is a different shape: a quantity across two
|
|
1490
1375
|
dimensions is `Matrix`, a managed grid is `DataGrid`.
|
|
1491
1376
|
|
|
@@ -1502,11 +1387,11 @@ outwards.
|
|
|
1502
1387
|
no row carries yet — that is data-driven shape, and it is allowed. What breaks is that the
|
|
1503
1388
|
`columns` entry and the row's `TableCell` are two edits in two files, so it is natural to make
|
|
1504
1389
|
only the first: the header band loses a heading, the row still renders the cell, and from there
|
|
1505
|
-
every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1390
|
+
every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws —
|
|
1391
|
+
the defect exists only in the pair. Derive both from one value (`const showX = …`, used for the
|
|
1392
|
+
`columns` filter AND passed to the row) so they cannot be edited apart. The signature when
|
|
1393
|
+
reviewing a render: the header has one fewer cell than the body, and the columns after the hidden
|
|
1394
|
+
one are shifted by exactly one slot.
|
|
1510
1395
|
|
|
1511
1396
|
**A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
|
|
1512
1397
|
`##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
|
|
@@ -1530,8 +1415,7 @@ recedes.
|
|
|
1530
1415
|
**The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
|
|
1531
1416
|
plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
|
|
1532
1417
|
often argues for a size step and should not get one: tabular figures and right alignment already
|
|
1533
|
-
mark it, and a lone larger value on a row reads as a mistake rather than as emphasis.
|
|
1534
|
-
thing among peers is not emphasis; it is the thing that looks broken.
|
|
1418
|
+
mark it, and a lone larger value on a row reads as a mistake rather than as emphasis.
|
|
1535
1419
|
|
|
1536
1420
|
**Whether the SUPPORTING line drops a rung is decided by what else separates the pair — not by
|
|
1537
1421
|
where it sits and not by what it carries.** `color="muted"` is already one channel of demotion, so
|
|
@@ -1552,9 +1436,8 @@ pair's, not each half's.
|
|
|
1552
1436
|
**A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
|
|
1553
1437
|
leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
|
|
1554
1438
|
object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
|
|
1555
|
-
a 40px mark the
|
|
1556
|
-
|
|
1557
|
-
is the smallest rung's own rule extended up the ladder rather than a second opinion about leading.
|
|
1439
|
+
a 40px mark the stack then comes to 50 and overhangs the mark it belongs to. Tight is `size + 6`,
|
|
1440
|
+
which is what `xs` already is — the smallest rung's own rule extended up the ladder.
|
|
1558
1441
|
|
|
1559
1442
|
Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
|
|
1560
1443
|
tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
|
|
@@ -1567,12 +1450,8 @@ Running text keeps prose leading. The default is for paragraphs, and `.ui-markdo
|
|
|
1567
1450
|
sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
|
|
1568
1451
|
the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
|
|
1569
1452
|
starts lower, and the pair reads with a wider gap than the identical pair one column over that
|
|
1570
|
-
happens to carry no control — one shared `gap` reading as two different ones.
|
|
1571
|
-
|
|
1572
|
-
It is invisible in review because every number is defensible — the gap is right, the rungs are
|
|
1573
|
-
right, the control is the documented size — and the defect is the interaction between them. It is
|
|
1574
|
-
also the harder half of *same shape, same treatment*: the reason for the difference is real, and a
|
|
1575
|
-
reader cannot see it.
|
|
1453
|
+
happens to carry no control — one shared `gap` reading as two different ones. It is invisible in
|
|
1454
|
+
review because every number is defensible and the defect is the interaction between them.
|
|
1576
1455
|
|
|
1577
1456
|
Pin the row to the TEXT's line box (`height` = the rung's leading) and let the control overflow it.
|
|
1578
1457
|
The hit area is unchanged — it paints outside the box rather than growing it — and the text sits
|
|
@@ -1587,17 +1466,14 @@ where it would if the control were not there. The same applies to any verb ridin
|
|
|
1587
1466
|
**The shape varies only where the surface MIXES entity types** and the reader acts on which is
|
|
1588
1467
|
which: a search result holding both people and companies, a "related records" list, a picker over
|
|
1589
1468
|
two directories. On a surface whose rows are all one kind of thing — a register of records, a
|
|
1590
|
-
roster, a list of accounts — pick ONE shape and hold it.
|
|
1591
|
-
|
|
1592
|
-
rather than as information. That is the general form: a structural device asserts a difference,
|
|
1469
|
+
roster, a list of accounts — pick ONE shape and hold it. A column of mixed circles and squares
|
|
1470
|
+
reads as an inconsistency rather than as information: a structural device asserts a difference,
|
|
1593
1471
|
and asserting one the reader cannot use costs more than it gives.
|
|
1594
1472
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
twenty
|
|
1598
|
-
|
|
1599
|
-
person's disc asserts they are the same kind of entity, which the column header alone has to
|
|
1600
|
-
correct.
|
|
1473
|
+
The mark's entire job is making a row identifiable at a glance, so it is the one element in the
|
|
1474
|
+
row that must not look the same on every row. A register of twenty people in one accent colour is
|
|
1475
|
+
twenty identical circles, with the largest and most colourful thing in the row carrying no
|
|
1476
|
+
information.
|
|
1601
1477
|
|
|
1602
1478
|
- **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
|
|
1603
1479
|
the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
|
|
@@ -1612,16 +1488,16 @@ correct.
|
|
|
1612
1488
|
- **Every register row should carry a LEADING MARK, best effort from the data.** An avatar, a
|
|
1613
1489
|
logo, a thumbnail, a file badge, a coloured initial — something the eye can aim at without
|
|
1614
1490
|
reading. A register whose rows open with text starts flat, and the symptom is reported as
|
|
1615
|
-
anything but the cause ("that avatar looks too small", "it looks plain")
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1491
|
+
anything but the cause ("that avatar looks too small", "it looks plain"). A person gets
|
|
1492
|
+
initials; an account gets the squared mark; a record with no likeness of its own borrows the
|
|
1493
|
+
identity a reader actually scans for — a deal takes its account's mark, since "whose deal is
|
|
1494
|
+
this" is the question being asked of the list. Only rows with genuinely no identity are exempt:
|
|
1495
|
+
a list of amounts, a log of events.
|
|
1620
1496
|
- **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
|
|
1621
1497
|
where it is one field among columns (see `avatar_size`).
|
|
1622
1498
|
- **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
|
|
1623
1499
|
can reach this", the commonest state is often *everyone* — and three sampled avatars assert
|
|
1624
|
-
something false about
|
|
1500
|
+
something false about it. Draw the
|
|
1625
1501
|
unbounded grant as a mark that SAYS so, and people only where the set is genuinely enumerable;
|
|
1626
1502
|
cap the faces at a few and let the remainder be a `+N` — as the last mark IN the stack, since a
|
|
1627
1503
|
number trailing after the discs reads as a separate fact about the row. Drop anyone the row
|
|
@@ -1630,23 +1506,19 @@ correct.
|
|
|
1630
1506
|
reader may not be entitled to. And the faces stay INERT inside a pressable row, per
|
|
1631
1507
|
member_peek.tsx.
|
|
1632
1508
|
- **An overlapping stack separates with translucent INK, never with the surface colour.** The
|
|
1633
|
-
conventional facepile rings each mark in the page background
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
of the four states, and they are the states a reader is in when they are actually looking. A
|
|
1509
|
+
conventional facepile rings each mark in the page background — which binds the ring to a colour
|
|
1510
|
+
the row does not keep. A register row washes on hover, again when it is the open record, and
|
|
1511
|
+
again while pressed, so a ring painted white is a halo in three of the four states. A
|
|
1637
1512
|
translucent dark edge darkens whatever is behind it instead of matching it, so it holds on every
|
|
1638
1513
|
ground; `FileBadge` separates itself from its own surface the same way. Use a `boxShadow`, not a
|
|
1639
1514
|
border — a border grows the box, and the stacked mark then measures wider than the identical
|
|
1640
1515
|
mark in the next column.
|
|
1641
1516
|
|
|
1642
|
-
The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
|
|
1643
|
-
|
|
1644
1517
|
## Character comes from the DATA — the answer when a screen reads "bland"
|
|
1645
1518
|
|
|
1646
1519
|
Every rule above removes a defect. None of them ADDS anything, so a screen can pass all of them
|
|
1647
1520
|
and still be reported as plain, boring, or unfinished — which is a different complaint with a
|
|
1648
|
-
different fix.
|
|
1649
|
-
*distinctive*.
|
|
1521
|
+
different fix.
|
|
1650
1522
|
|
|
1651
1523
|
- **What is inherently visual in THIS data?** Work software has no photography, so the data itself
|
|
1652
1524
|
is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
|
|
@@ -1661,36 +1533,31 @@ different fix. Removing defects converges on *inoffensive*; these three question
|
|
|
1661
1533
|
keep not landing.
|
|
1662
1534
|
- **What makes this belong to THIS product?** Default typeface, default greys, default accent,
|
|
1663
1535
|
default control shapes is a *well-built generic screen*, and no further polish changes that. The
|
|
1664
|
-
fix at that point is a theme (`applyLoticsTheme`), not another treatment pass
|
|
1665
|
-
which of the two you are looking at saves a great deal of pointless nudging.
|
|
1536
|
+
fix at that point is a theme (`applyLoticsTheme`), not another treatment pass.
|
|
1666
1537
|
|
|
1667
1538
|
Type SCALE is the fourth, and it belongs with the others: range = largest ÷ smallest across the
|
|
1668
|
-
screen, and under about 2× reads flat *at any amount of colour*.
|
|
1669
|
-
colour cannot substitute for. A register with no page band tops out near 1.33×.
|
|
1539
|
+
screen, and under about 2× reads flat *at any amount of colour*.
|
|
1670
1540
|
|
|
1671
1541
|
**The test every visual has to pass: does it make the screen CLEARER *and* better-looking?** Both,
|
|
1672
1542
|
not either. A visual that only decorates is clutter and goes; a visual that only informs but fights
|
|
1673
1543
|
the composition is a chart nobody reads. The ones that pay are the ones where the two are the same
|
|
1674
1544
|
act — a status colour that is also the scan path, a product thumbnail that is also the identifier, a
|
|
1675
|
-
trend line that is also the answer.
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
screen that
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
is simply the one that survives review, because nothing on the screen looks wrong. Ask what the
|
|
1686
|
-
reader must DECIDE, then check that the screen holds every fact that decision needs.
|
|
1545
|
+
trend line that is also the answer.
|
|
1546
|
+
|
|
1547
|
+
**And the same test cuts the other way.** Nearly every rule here removes something — follow only
|
|
1548
|
+
that gradient and you converge on a screen that is clean, defensible, and *does not tell the reader
|
|
1549
|
+
enough to act*. **Minimal is not the same as clear.** A number with no comparison, a status with no
|
|
1550
|
+
age, a total with no composition, a row that forces a click to answer the question it exists to
|
|
1551
|
+
answer — each is a screen that passed every subtraction and failed the reader. Withholding is a
|
|
1552
|
+
defect exactly as much as clutter is; it is simply the one that survives review, because nothing on
|
|
1553
|
+
the screen looks wrong. Ask what the reader must DECIDE, then check that the screen holds every
|
|
1554
|
+
fact that decision needs.
|
|
1687
1555
|
|
|
1688
1556
|
**Where the data has a history, show the trajectory, not just the latest value.** A figure states
|
|
1689
1557
|
where things stand; a line, a bar series or a `Sparkline` states where they are GOING, and the
|
|
1690
1558
|
direction is usually the decision — a balance that is falling, a return rate that is climbing, a
|
|
1691
1559
|
week that broke the pattern. This is the most common honest source of visual interest in work
|
|
1692
|
-
software, because the shape *is* information.
|
|
1693
|
-
current value is what the query returns first.
|
|
1560
|
+
software, because the shape *is* information.
|
|
1694
1561
|
|
|
1695
1562
|
**A register whose entity has ordered stages should render the stage, not just name it.** A word in
|
|
1696
1563
|
a cell makes the reader know the vocabulary and its order; a small coloured progress indicator makes
|
|
@@ -1711,8 +1578,8 @@ case. `primary` is already the ink the kit spends on "this is on": a checkbox's
|
|
|
1711
1578
|
track, a picked option's tick, the one primary button. An active tab is the same claim at page
|
|
1712
1579
|
scale, so it belongs to the same ink, and the rail then sits at the same weight as the CTA it
|
|
1713
1580
|
shares a screen with. Painted in `accent`, an unthemed screen gets exactly one blue thing on it —
|
|
1714
|
-
a single-member hue from a family used nowhere else, which is
|
|
1715
|
-
|
|
1581
|
+
a single-member hue from a family used nowhere else, which is a library default nobody chose (see
|
|
1582
|
+
§ Color discipline).
|
|
1716
1583
|
|
|
1717
1584
|
The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
|
|
1718
1585
|
filters), `primary` marks THIS IS ON (a control's state, the section you are in).
|
|
@@ -1723,51 +1590,41 @@ keeping the ground every other chip and card has. The two altitudes deliberately
|
|
|
1723
1590
|
is the row's own language, so a control that borrows it says the same thing twice, and a control
|
|
1724
1591
|
has a border to say it with while a row does not.
|
|
1725
1592
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
the busiest surface in the product; making the token neutral to fix that turned a themed app's drop
|
|
1729
|
-
target grey and left a token named for the brand that resolved to zinc. Selections carry their own
|
|
1730
|
-
LITERALS now, so "is this selected" never depends on whether a tenant set an accent, and
|
|
1731
|
-
`accent_wash` goes back to meaning what it says: the brand's tint, spent on ATTENTION — a region
|
|
1593
|
+
Selections carry their own LITERALS, so "is this selected" never depends on whether a tenant set
|
|
1594
|
+
an accent, and `accent_wash` means what it says: the brand's tint, spent on ATTENTION — a region
|
|
1732
1595
|
lighting up under a dragged file — never on which record is open.
|
|
1733
1596
|
|
|
1734
|
-
**Selection and hover must not share a ground
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
it.
|
|
1597
|
+
**Selection and hover must not share a ground** — the register's one piece of persistent state is
|
|
1598
|
+
then erased by a transient one: the row whose record is open looks exactly like the row under the
|
|
1599
|
+
pointer. One neutral step separates them — **hover zinc-50, selected zinc-100, pressed zinc-200**,
|
|
1600
|
+
three rungs of the ramp every other pressable already uses to say the same three things. A control
|
|
1601
|
+
marked by an EDGE sidesteps the collision entirely, which is the other reason chips are marked that
|
|
1602
|
+
way: the edge survives the hover wash instead of being replaced by it.
|
|
1741
1603
|
|
|
1742
1604
|
### Theming is an app-level ESCAPE HATCH, not a product surface
|
|
1743
1605
|
|
|
1744
1606
|
An app sets its own identity in its own code — `applyLoticsTheme({ … })` once at boot, before
|
|
1745
1607
|
`mount`. **Nothing in the platform writes a theme**,
|
|
1746
1608
|
there is no settings screen for it, and `apps.theme.color` is the LAUNCHER ICON's colour and
|
|
1747
|
-
nothing else.
|
|
1748
|
-
exists because a customer occasionally needs their own typeface, not because theming is a feature
|
|
1749
|
-
we sell.
|
|
1609
|
+
nothing else. That is the boundary, not a gap waiting to be filled.
|
|
1750
1610
|
|
|
1751
1611
|
**What makes it possible is small and load-bearing.** `colors.web.ts` reads five roles through a
|
|
1752
1612
|
CSS variable with the kit's own value as the inline fallback, and `font_family.web.ts` does the
|
|
1753
1613
|
same for the three weight rungs. That indirection is the ENTIRE mechanism — and it cannot be
|
|
1754
|
-
replaced by wrapping a component and passing styles down
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
than a theme.
|
|
1614
|
+
replaced by wrapping a component and passing styles down. React Native Web writes `fontFamily`
|
|
1615
|
+
onto every `Text` element itself, so a wrapper's font has nothing to inherit into; and
|
|
1616
|
+
`background-color` and `border-color` do not inherit in CSS at all, so no ancestor can reach a
|
|
1617
|
+
`Button`'s fill or a `Table`'s hairlines. The only alternative to the variables is passing `style`
|
|
1618
|
+
at every call site, which is a fork rather than a theme.
|
|
1760
1619
|
|
|
1761
1620
|
**Prefer `applyLoticsTheme` over declaring the variables by hand**, because two of the values are
|
|
1762
1621
|
DERIVED and getting either wrong fails quietly: `accent_wash` is the accent at 7%, and one
|
|
1763
1622
|
`bodyFont` has to reach all three weight rungs or the medium and semibold runs stay in the kit's
|
|
1764
1623
|
own face and the screen reads as a font that half-loaded.
|
|
1765
1624
|
|
|
1766
|
-
**It is a function, not a provider
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
with no lifecycle. Calling it before `mount` is also strictly better than a provider could be: the
|
|
1770
|
-
variables are in place for the FIRST paint, where a provider's effect ran after one.
|
|
1625
|
+
**It is a function, not a provider**: an app declares one identity at boot and never changes it,
|
|
1626
|
+
so there is no lifecycle to buy. Calling it before `mount` puts the variables in place for the
|
|
1627
|
+
FIRST paint, where a provider's effect would run after one.
|
|
1771
1628
|
|
|
1772
1629
|
## Color discipline — solid / tint / ramp + ONE accent
|
|
1773
1630
|
|
|
@@ -1804,9 +1661,8 @@ the sizes are.
|
|
|
1804
1661
|
`color="muted"` is the token that means "this supports something else"; a hand-written
|
|
1805
1662
|
`color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
|
|
1806
1663
|
already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
|
|
1807
|
-
and a
|
|
1808
|
-
|
|
1809
|
-
component for a raw neutral on `Text` before trusting that it reads muted.
|
|
1664
|
+
and on a departed row the mute and the metadata come out identical, so the state stops signalling
|
|
1665
|
+
anything. Grep a component for a raw neutral on `Text` before trusting that it reads muted.
|
|
1810
1666
|
|
|
1811
1667
|
**TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
|
|
1812
1668
|
`Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
|
|
@@ -1833,8 +1689,7 @@ cue. That is the ONLY thing it is for; keep the vocabulary scarce so a colored c
|
|
|
1833
1689
|
status. **A type / category / attribute / count is NOT a status — render it as inline `Text` (a
|
|
1834
1690
|
muted metadata line, free to wrap the row taller), NEVER a Badge.** A party's type, a document
|
|
1835
1691
|
kind, a city, "3 files" → text. If you're reaching for a Badge in a row or an option list "to
|
|
1836
|
-
show more info," STOP — that's text.
|
|
1837
|
-
through combobox option rows.)
|
|
1692
|
+
show more info," STOP — that's text.
|
|
1838
1693
|
|
|
1839
1694
|
When it IS a status, match weight to prominence — lightest → heaviest:
|
|
1840
1695
|
|
|
@@ -1854,9 +1709,9 @@ Prefer `dot` when the column is secondary or the row already carries a coloured
|
|
|
1854
1709
|
`tonal` when the status column is what the register is SCANNED by — the tints are the palette's
|
|
1855
1710
|
50-step against its 900-step ink, comfortably clear of the AA floor, and
|
|
1856
1711
|
a column of them is the difference between a register that reads as organised and one that reads
|
|
1857
|
-
as a grey spreadsheet. That
|
|
1858
|
-
|
|
1859
|
-
|
|
1712
|
+
as a grey spreadsheet. That is the commonest "it looks bland" report: make the colour the screen
|
|
1713
|
+
ALREADY earns more present, rather than adding colour somewhere that has to invent a meaning to
|
|
1714
|
+
justify it.
|
|
1860
1715
|
|
|
1861
1716
|
**A `Badge` in a table cell needs `alignSelf`.** A cell stretches its child on the cross axis, and
|
|
1862
1717
|
a `dot` has no ground so nothing shows — swap the variant to `tonal` and the pill becomes a filled
|
|
@@ -1874,9 +1729,8 @@ not a form they fill down. Stacked one per row at the page's own rhythm, a handf
|
|
|
1874
1729
|
values can spend a quarter of the first screen while the viewport's right half sits empty. Pair
|
|
1875
1730
|
them into columns above the width where a label and its value still fit.
|
|
1876
1731
|
|
|
1877
|
-
The tell is a screen reported as **plain** whose treatment is fine
|
|
1878
|
-
|
|
1879
|
-
its width on nothing.
|
|
1732
|
+
The tell is a screen reported as **plain** whose treatment is fine: the page is spending its height
|
|
1733
|
+
on facts and its width on nothing.
|
|
1880
1734
|
|
|
1881
1735
|
**A record page also owes the reader the SIZE of its subject.** Where the data is quantities — how
|
|
1882
1736
|
many lines, how heavy, worth what — a page that holds them only inside sections makes a reader open
|
|
@@ -1905,11 +1759,9 @@ cluttered rather than as small. Recede with INK, at the same rung. `xs` is for g
|
|
|
1905
1759
|
timestamp, a count), not for a label that a person actually has to read.
|
|
1906
1760
|
|
|
1907
1761
|
**A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
|
|
1908
|
-
flex row, and whichever cannot shrink forces the other to.
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
values happen to fit, which is usually the width it was built at, and it appears the first time a
|
|
1912
|
-
real record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
|
|
1762
|
+
flex row, and whichever cannot shrink forces the other to. It is invisible wherever the values
|
|
1763
|
+
happen to fit, which is usually the width it was built at, and it appears the first time a real
|
|
1764
|
+
record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
|
|
1913
1765
|
whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
|
|
1914
1766
|
and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
|
|
1915
1767
|
recognisably a phone number.
|
|
@@ -1940,13 +1792,9 @@ steepens as the type grows — nothing in the kit tracks positive except `transf
|
|
|
1940
1792
|
which compensates for the caps itself.
|
|
1941
1793
|
|
|
1942
1794
|
A hand-set `letterSpacing` is a finding wherever it appears, and it fails in a specific way: it
|
|
1943
|
-
looks like a local decision and is actually a second copy of the curve
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
itself that was the whole of native's tracking, and `+0.3` left on a column header after its size
|
|
1947
|
-
and weight had been corrected. Each was defensible in its own file and wrong on the screen. If
|
|
1948
|
-
something cannot be wrapped in `Text` (a raw `TextInput`, a chart axis), take the rung from
|
|
1949
|
-
`type_ramp` rather than picking a number.
|
|
1795
|
+
looks like a local decision and is actually a second copy of the curve — defensible in its own
|
|
1796
|
+
file and wrong on the screen. If something cannot be wrapped in `Text` (a raw `TextInput`, a chart
|
|
1797
|
+
axis), take the rung from `type_ramp` rather than picking a number.
|
|
1950
1798
|
|
|
1951
1799
|
Weights: `regular` / `medium` /
|
|
1952
1800
|
`semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
|
|
@@ -1966,9 +1814,8 @@ equal — and the reader has to reconstruct the grouping from the content, becau
|
|
|
1966
1814
|
telling them the parts are peers of the wholes. The tell is a stack that reads as an undifferentiated
|
|
1967
1815
|
list when you unfocus your eyes, or a supporting line drifting so far from the value it supports
|
|
1968
1816
|
that it looks like the next unit's first line. This is the same law the gap-ratio probe in
|
|
1969
|
-
[reviewing.md](./reviewing.md) measures (~2:1 between over inside)
|
|
1970
|
-
|
|
1971
|
-
spacing groups the wrong things.
|
|
1817
|
+
[reviewing.md](./reviewing.md) measures (~2:1 between over inside) — no amount of type or colour
|
|
1818
|
+
work fixes a stack whose spacing groups the wrong things.
|
|
1972
1819
|
|
|
1973
1820
|
**A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
|
|
1974
1821
|
Padding that happens to sum to the band height is a second owner of that number, and it drifts the
|
|
@@ -1998,10 +1845,9 @@ React Native Web maps a subset of RN's accessibility props and **silently drops
|
|
|
1998
1845
|
<Pressable accessibilityRole="tab" aria-selected={isActive} />
|
|
1999
1846
|
```
|
|
2000
1847
|
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
Nothing in a normal loop surfaces it, so it survives review, tests and QA.
|
|
1848
|
+
There is no type error — the prop is declared — no runtime warning, and the control looks and
|
|
1849
|
+
behaves correctly; only a screen reader and the DOM inspector disagree with the source. Nothing in
|
|
1850
|
+
a normal loop surfaces it, so it survives review, tests and QA.
|
|
2005
1851
|
|
|
2006
1852
|
Write the raw `aria-*` attribute for any STATE (`aria-selected`, `aria-checked`,
|
|
2007
1853
|
`aria-expanded`, `aria-disabled`, `aria-current`). `accessibilityRole` and
|
|
@@ -2017,8 +1863,7 @@ no matter how long you `waitFor`. That covers everything built on it: the `Combo
|
|
|
2017
1863
|
this reason — there is no render test for any of them.
|
|
2018
1864
|
|
|
2019
1865
|
The trap is that the CONTROL works fine, so the failure looks like a wiring bug: the input takes
|
|
2020
|
-
its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere.
|
|
2021
|
-
debugging the component instead of the harness.
|
|
1866
|
+
its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere.
|
|
2022
1867
|
|
|
2023
1868
|
So split the assertion:
|
|
2024
1869
|
|
|
@@ -2032,6 +1877,42 @@ A component test can still assert everything BEFORE the overlay opens: the empty
|
|
|
2032
1877
|
placeholder, the disabled state, and — often the point — what the screen refuses to claim while
|
|
2033
1878
|
nothing is selected.
|
|
2034
1879
|
|
|
1880
|
+
## Localization — a rendered string is WIRED, never a literal
|
|
1881
|
+
|
|
1882
|
+
Microcopy below says what the words should SAY. This says how they reach the screen, and it is
|
|
1883
|
+
the same family of defect as `accessibilityState` above: it typechecks, it renders, and it is
|
|
1884
|
+
wrong in a language nobody on the change speaks.
|
|
1885
|
+
|
|
1886
|
+
**No user-facing string is a literal in `src/` when a locale slice already names it.** Every
|
|
1887
|
+
string a component renders resolves **prop → `useLoticsLocale()` slice → the English pack**: a
|
|
1888
|
+
per-instance `labels` override wins, the pack from `LoticsLocaleProvider` answers next, and `en`
|
|
1889
|
+
is the context's default value rather than a per-key fallback — each slice reuses the
|
|
1890
|
+
component's own `*Labels` type, so both packs are compile-forced complete and a missing
|
|
1891
|
+
translation is a build error, never a silent English leak at a call site. The module itself —
|
|
1892
|
+
the provider, the shipped `en`/`vi` packs, the `bcp47` tag, and the few surfaces that still take
|
|
1893
|
+
`labels` directly — is [catalog.md §"Theming, locale & tokens"](./catalog.md).
|
|
1894
|
+
|
|
1895
|
+
**Write it as an ALLOWLIST, not a hunt.** The question is never "does this string look
|
|
1896
|
+
English?" — it is "which slice names this string?", asked of every run of language the component
|
|
1897
|
+
emits, visible text and accessible names alike. A hunt is a denylist against a language you can
|
|
1898
|
+
recognise, and it goes blind twice over:
|
|
1899
|
+
|
|
1900
|
+
- **A literal typechecks, renders, and passes a render test in whatever language it happens to
|
|
1901
|
+
be written in.** The kit shipped a hardcoded Vietnamese ⓘ label — the exact value of the very
|
|
1902
|
+
slot it should have read — to every tenant, and a whole suite of `vi`-pack assertions was
|
|
1903
|
+
blind to it, because on that bug the literal and the translation are the same string.
|
|
1904
|
+
- **A string BUILT by concatenation hides inside caller data.** `Open ${name}`, `About ${label}`,
|
|
1905
|
+
`${label} min` each carry one English word welded onto a value, so a Vietnamese app announced
|
|
1906
|
+
"Open hop_dong.pdf" to the one reader who has only the announcement. The fix is a pack-owned
|
|
1907
|
+
FUNCTION, not a connector string: word order forks by language, and a pack that only supplies
|
|
1908
|
+
the word cannot move it.
|
|
1909
|
+
|
|
1910
|
+
**So assert a wired string under the OTHER language.** English is what the bug produces, so an
|
|
1911
|
+
`en` assertion passes on the defect; render under `vi` and match the accessible name EXACTLY —
|
|
1912
|
+
a substring match contains the caller's data either way and is blind to precisely this. The one
|
|
1913
|
+
inversion: a string hardcoded to the Vietnamese value can only be caught under `en`. Whichever
|
|
1914
|
+
language the literal is in, the proof is the other one.
|
|
1915
|
+
|
|
2035
1916
|
## Microcopy — content, not mechanics
|
|
2036
1917
|
|
|
2037
1918
|
UI copy states WHAT something is — domain content or one real rule — never how the widget
|
|
@@ -2061,8 +1942,8 @@ that praises it is marketing.
|
|
|
2061
1942
|
- **NEVER join text with a middot — ` · ` is banned.** It is punctuation doing a word's job: it
|
|
2062
1943
|
asserts that two things are related while refusing to say how, leaving the reader to guess
|
|
2063
1944
|
between "and", "of", "for", "then", and "here is another fact". It is also the single loudest
|
|
2064
|
-
templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it
|
|
2065
|
-
|
|
1945
|
+
templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it. Use the
|
|
1946
|
+
mark that MEANS what you intend:
|
|
2066
1947
|
**`:`** for a run-in label naming its value (`Hàng hoá: <goods>`), **`( )`** for a
|
|
2067
1948
|
supplementary count or qualifier (`Đã đọc biểu thuế (2 bước)`), **`;`** between two related
|
|
2068
1949
|
clauses, **`,`** in a genuine list of like items, a real preposition when the relation is
|
|
@@ -2109,8 +1990,7 @@ one exists).
|
|
|
2109
1990
|
|
|
2110
1991
|
An empty region has three jobs (NN/g): confirm this is EMPTY — not loading, not broken — and
|
|
2111
1992
|
say what belongs here. The `EmptyState` props are that anatomy and nothing more (`message` =
|
|
2112
|
-
what's empty, `hint` = what to do about it).
|
|
2113
|
-
component: an alert glyph inside an empty state says both at once and means neither.
|
|
1993
|
+
what's empty, `hint` = what to do about it).
|
|
2114
1994
|
|
|
2115
1995
|
**`EmptyState` carries NO verb.** The add is on the heading row (§ The add-placement law) and
|
|
2116
1996
|
duplicating it here would put two buttons for one act in view and make the add jump the moment
|
|
@@ -2130,10 +2010,8 @@ default holds a screenful because nothing else is in view: collapsing to two gre
|
|
|
2130
2010
|
read as a rendering bug rather than a state, which is the first of the three jobs. Inside a
|
|
2131
2011
|
record that premise is false. The band's heading above it, the sections below it and the add
|
|
2132
2012
|
verb beside it have already proved the surface rendered; the only open question is what this one
|
|
2133
|
-
band holds, and a screenful is a long way to say "nothing yet"
|
|
2134
|
-
|
|
2135
|
-
the section the reader opened the record FOR under the fold on a 768px screen — every probe
|
|
2136
|
-
passes on that page, because nothing is wrong with any element on it.
|
|
2013
|
+
band holds, and a screenful is a long way to say "nothing yet" — on a four-section record where
|
|
2014
|
+
three bands can be empty at once, the default spends **178px per band**.
|
|
2137
2015
|
|
|
2138
2016
|
So: `compact` on a band, the default on a region. It is a DENSITY axis, not a second component —
|
|
2139
2017
|
same three jobs, same two strings, same no-verb rule. Compact also sets its text to the LEFT,
|
|
@@ -2146,11 +2024,9 @@ at a fixed stroke on a 24 viewBox, so the artwork fills 20/24 of whatever box yo
|
|
|
2146
2024
|
`sm` text the CAP HEIGHT is 11px, so a mark has to be ~13px to sit ON the line instead of
|
|
2147
2025
|
straddling it — and at 13–16px a glyph with interior detail (`package`, `receipt`, `file-text`)
|
|
2148
2026
|
closes up into a smudge. At 20px it reads, and its 16.7px of artwork then stands half again as
|
|
2149
|
-
tall as the capitals beside it, rising above them and dropping past the baseline.
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
wants the block form, where it gets 28px and a line of its own. Here the anchoring is already
|
|
2153
|
-
done by the band's heading, which is the premise the whole variant rests on.
|
|
2027
|
+
tall as the capitals beside it, rising above them and dropping past the baseline. Reach for a mark
|
|
2028
|
+
beside text only where the glyph is SIMPLE (`check`, a chevron, a dot) or the text is `md` or
|
|
2029
|
+
larger; a detailed glyph wants the block form, where it gets 28px and a line of its own.
|
|
2154
2030
|
|
|
2155
2031
|
### Buttons & action labels — the outcome, verb-first
|
|
2156
2032
|
|