@lotics/ui 45.9.0 → 45.10.1
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/docs/ai_patterns.md +88 -150
- package/docs/catalog.md +243 -285
- package/docs/composition.md +455 -465
- package/docs/data_entry.md +109 -155
- package/docs/reviewing.md +47 -55
- package/docs/templates.md +480 -371
- package/docs/testing.md +3 -7
- package/package.json +3 -1
- package/src/bar_chart.tsx +20 -2
- package/src/chip_group.tsx +5 -3
- package/src/finding.tsx +18 -3
- package/src/line_chart.tsx +5 -14
- package/src/line_chart_labels.ts +32 -0
- package/src/page_header.tsx +13 -1
- package/src/pressable_row.tsx +7 -5
- package/src/stacked_bar_chart.tsx +197 -0
- package/src/table.tsx +15 -14
- package/src/waterfall_chart.tsx +398 -0
package/docs/composition.md
CHANGED
|
@@ -10,30 +10,55 @@ them produces off-system UI.
|
|
|
10
10
|
|
|
11
11
|
## The form comes before the treatment
|
|
12
12
|
|
|
13
|
+
**If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
|
|
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, 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
|
+
Go and look at how the industry does it before designing something new; a shape that is unfamiliar
|
|
19
|
+
in a domain that has a familiar one is a cost paid by every user, forever, to no end.
|
|
20
|
+
|
|
21
|
+
That is a licence, not a leash. **The rules in this document constrain treatment, not invention.**
|
|
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. 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.
|
|
26
|
+
|
|
27
|
+
**Which rules bind, and which advise.** Read every rule here for what it ENCODES, because the two
|
|
28
|
+
kinds are answered differently:
|
|
29
|
+
|
|
30
|
+
- **A rule that prevents a known HARM binds.** A page that scrolls sideways on a phone, an act that
|
|
31
|
+
cannot be undone, a figure that is typed where it should be computed, a machine's guess rendered
|
|
32
|
+
like a person's decision, a control a keyboard cannot reach — these are settled.
|
|
33
|
+
- **A rule that picks among valid FORMS advises.** Which shape fits this subject, how a set is cut,
|
|
34
|
+
what the reader sees first — the space of jobs is unbounded and the best answer is regularly one
|
|
35
|
+
no document here has named. Treat these as the accumulated experience they are, then decide.
|
|
36
|
+
|
|
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.
|
|
41
|
+
|
|
13
42
|
**Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
|
|
14
43
|
through ordered places is a rail; work assigned to finite resources over time is a board; a
|
|
15
44
|
sequence of events is a timeline; a population whose point is its deviant minority is a queue
|
|
16
45
|
sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
|
|
17
|
-
should have been a rail
|
|
18
|
-
|
|
19
|
-
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.
|
|
20
48
|
|
|
21
49
|
**A hierarchy in the DATA does not oblige a hierarchy in the UI.** A parent id, a materialized
|
|
22
50
|
path, a folder column — each is a real structure, and none of them decides the screen's form. Ask
|
|
23
51
|
what the reader DOES with the set: a population they walk (a chart of accounts, a bill of
|
|
24
|
-
materials
|
|
25
|
-
|
|
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
|
|
26
54
|
distinction is who maintains the structure. Where something else writes it — an importer, an
|
|
27
55
|
integration, an agent — a drill-down makes the reader navigate a shape they did not choose and
|
|
28
56
|
cannot see the whole of, while a flat register with the address on the row costs one line and
|
|
29
57
|
loses nothing. Build the tree when walking it IS the task, not because the column exists.
|
|
30
58
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
first is about not duplicating primitives** — a one-off composition needs no new component, so the
|
|
35
|
-
kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
|
|
36
|
-
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.
|
|
37
62
|
|
|
38
63
|
## Canvas & content column
|
|
39
64
|
|
|
@@ -44,11 +69,22 @@ subject into rows.
|
|
|
44
69
|
the same left edge as the title above it and the actions below it. Never hand-pad inside one,
|
|
45
70
|
and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
|
|
46
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.
|
|
47
83
|
- **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
|
|
48
84
|
ends short of the surface with dead space beneath it and its last row clipped. A pinned
|
|
49
|
-
heading above a scrolling list therefore takes the
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
52
88
|
- **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
|
|
53
89
|
sized to the job, with `gap: 16` between top-level bands/cards:
|
|
54
90
|
|
|
@@ -68,17 +104,16 @@ constant rather than happening to match today.
|
|
|
68
104
|
The offender is nearly always a row that PADDED for its own hover wash instead of bleeding it
|
|
69
105
|
(`ROW_WASH_BLEED`), which pushes its content in by the bleed while everything above it stays put.
|
|
70
106
|
Fix the row, then DELETE the compensators that were added around it — once the row is right they
|
|
71
|
-
become double-corrections
|
|
72
|
-
|
|
73
|
-
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
|
|
74
109
|
checkbox, an ordinal, an identity mark — is explained by the thing occupying it.
|
|
75
110
|
|
|
76
111
|
**A row beat belongs to the PAGE, not to the row.** Collect every row primitive's height on one
|
|
77
112
|
screen before calling any of them right — the kit's own defaults do not agree with each other,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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).
|
|
82
117
|
|
|
83
118
|
## The heading ramp — one construct per altitude, no drift
|
|
84
119
|
|
|
@@ -98,25 +133,22 @@ restyle a heading level per-page.
|
|
|
98
133
|
20px bar — a heading shouting over the pane that holds it. The CONTAINER publishes the altitude
|
|
99
134
|
and one `SectionHeadingTitle` answers to it: `##` on a page, `####` inside `DrawerScrollArea` /
|
|
100
135
|
`DialogScrollArea` / `ModalBody` / `PopoverContent`. Write the same heading either way; there is
|
|
101
|
-
no per-surface variant to remember
|
|
102
|
-
their heading will sooner or later be wrong about it.
|
|
136
|
+
no per-surface variant to remember.
|
|
103
137
|
|
|
104
138
|
Three consequences worth knowing before you compose:
|
|
105
139
|
|
|
106
140
|
- **A whole PAGE surface docked in an overlay keeps the page rung.** The master-detail register
|
|
107
141
|
puts a full record screen in a drawer; that screen brings its own `#` identity band, carries
|
|
108
|
-
its own gutters, and therefore sits in the container's BARE slot rather than the scroll area
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
112
146
|
- **A pane you build YOURSELF declares its own altitude.** A docked side panel is not an overlay
|
|
113
147
|
and uses none of those regions, but its headings must not shout across 400px either — wrap its
|
|
114
148
|
content in `<HeadingAltitudeContext.Provider value="panel">` (`@lotics/ui/heading_altitude`).
|
|
115
|
-
A rule scoped to the containers the kit happens to render is an allowlist short by however many
|
|
116
|
-
panes the app builds.
|
|
117
149
|
- **In a pane, `####` is the FLOOR.** `SubsectionHeadingTitle` has one altitude (`###` lg) and
|
|
118
150
|
would out-rank the section holding it, so a named group inside a pane's section is
|
|
119
|
-
`Text size="sm" weight="semibold"
|
|
151
|
+
`Text size="sm" weight="semibold"`.
|
|
120
152
|
|
|
121
153
|
Nesting never compounds: a popover opened inside a drawer is one rung down, not two.
|
|
122
154
|
|
|
@@ -126,13 +158,11 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
126
158
|
hierarchy device colour cannot substitute for. Restoring a `Text size="xxl" weight="semibold"`
|
|
127
159
|
title takes the same screen to ~2.7x. It also gives the page somewhere to land: without it the
|
|
128
160
|
first thing on the screen is a search input, which is chrome.
|
|
129
|
-
Yes, the title usually repeats the nav. Accept the repetition —
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
register's counts are not that line either: they are the result of the TOOLBAR, so they belong
|
|
135
|
-
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.
|
|
136
166
|
Right side: the screen's ONE primary action and/or a period filter — never a
|
|
137
167
|
summary `Badge` (those belong to the KPI strip).
|
|
138
168
|
- **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
|
|
@@ -143,10 +173,9 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
143
173
|
lowest heading rank — and neither of the two constructs it sits between: `CardHeaderTitle`
|
|
144
174
|
(`sm`) labels a CONTAINER of data, whereas here the name IS the content; `RecordSummary`'s `#`
|
|
145
175
|
is a page/drawer rung, and a page rung inside a popover flattens the very altitude distinction
|
|
146
|
-
the ramp exists to keep.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
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
|
|
150
179
|
other muted lines in the same block), which is what "one rung below" is serving; applied
|
|
151
180
|
literally against that it produces a singleton treatment instead.
|
|
152
181
|
- **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
|
|
@@ -156,14 +185,12 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
156
185
|
below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
|
|
157
186
|
drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
|
|
158
187
|
up whatever is left, taking the `description` down with it. A label that repeats the heading
|
|
159
|
-
verbatim is the usual way this gets long enough to bite
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
|
|
163
|
-
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,
|
|
164
191
|
paged, or long enough that the reader would have to scan to know. Over a list or thread
|
|
165
|
-
rendered whole directly beneath its own heading it restates what is already in view
|
|
166
|
-
|
|
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
|
|
167
194
|
between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
|
|
168
195
|
null/false children are skipped so a conditional section never leaves a stray hairline). The
|
|
169
196
|
`Divider` NEVER goes directly under a heading — that orphans the title from its own content.
|
|
@@ -173,10 +200,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
173
200
|
sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
|
|
174
201
|
- **Pane section title** — the FOURTH rung, taken automatically by `SectionHeadingTitle`
|
|
175
202
|
inside a pane's content region. Same construct, same `icon`/`description`/`info` slots;
|
|
176
|
-
only the type size steps down, and nothing at the call site says so.
|
|
177
|
-
|
|
178
|
-
occupies — so a panel holding a multi-section surface otherwise ties with, or is outranked by,
|
|
179
|
-
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` ›
|
|
180
205
|
this, in a `SubsectionStack`): the 12px body gap and 32px beat are already right for a panel.
|
|
181
206
|
The ladder stays ABSOLUTE — this is one more fixed rung, never a `size`/`compact` knob.
|
|
182
207
|
- **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
|
|
@@ -197,11 +222,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
|
|
|
197
222
|
to its own section-level heading just to separate it.
|
|
198
223
|
- **Eyebrow vs group lead — a label is one or the other, and the difference is what it
|
|
199
224
|
LABELS.** Each is a COMPONENT — `@lotics/ui/eyebrow` and `@lotics/ui/group_lead` — so the call
|
|
200
|
-
site DECLARES which one it is rather than re-deriving a rung from feel
|
|
201
|
-
|
|
202
|
-
way; a shipped app then labelled three act-on-it blocks at the eyebrow's `xs` muted, which is
|
|
203
|
-
the failure the prose could not prevent. Neither takes a `size`, `weight` or `transform`: the
|
|
204
|
-
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.
|
|
205
227
|
|
|
206
228
|
| | Eyebrow | Group lead |
|
|
207
229
|
|---|---|---|
|
|
@@ -239,11 +261,9 @@ altitude (`SubsectionHeading`, `CardHeader`).
|
|
|
239
261
|
|
|
240
262
|
The rule is about a position that does not MOVE. Put the add under the register it extends and
|
|
241
263
|
where the reader looks for it depends on how many rows there already are — past a screenful the
|
|
242
|
-
verb is off-screen, and on an empty list there is no last row to sit under
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
and the view controls — so the add belongs on it, in the same spot whether the section holds
|
|
246
|
-
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.
|
|
247
267
|
|
|
248
268
|
**When the row runs out of width, the VERB wraps — the title never gives way.** The title names
|
|
249
269
|
the section; the verb is the thing that can move. Getting this backwards is easy to ship because
|
|
@@ -251,12 +271,10 @@ RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title be
|
|
|
251
271
|
holds its intrinsic width is the half that collapses, and at phone width the heading breaks one
|
|
252
272
|
word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
|
|
253
273
|
their title column for this; do not add a second `flex: 1` spacer beside a title that already has
|
|
254
|
-
one, which halves its share again
|
|
274
|
+
one, which halves its share again.
|
|
255
275
|
|
|
256
276
|
**Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
|
|
257
|
-
bar** — an attachment popover, a dialog body, any self-contained widget.
|
|
258
|
-
to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
|
|
259
|
-
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
|
|
260
278
|
exactly this case; inside a section the host puts its own `Button` on the heading instead, because
|
|
261
279
|
`FilesEditor` provides the context and the heading is outside it — and a file widget rendering a
|
|
262
280
|
`SectionHeading` would own something the section owns. Worked both ways in `tpl_record` (Files,
|
|
@@ -264,19 +282,15 @@ Photos) and `tpl_task_board` (the attachment popover).
|
|
|
264
282
|
|
|
265
283
|
**A header CHECKBOX is legible only when it heads a COLUMN.** A table's select-all works because
|
|
266
284
|
it sits at the same x as every row's box beneath it — the column is what turns a 24px tick into
|
|
267
|
-
"all of these". A grid has no such column, so the same control in a section heading heads
|
|
268
|
-
nothing: it is a transparent 24×24 at radius 6 standing between 40px grounded buttons at radius
|
|
269
|
-
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
|
|
270
286
|
grid's select-all is a BUTTON** ("Select all" / "Deselect all"), which is what
|
|
271
287
|
`FilesEditorSelectAll` ships. Nothing is lost by giving up the tri-state: `indeterminate` says
|
|
272
|
-
"some", and the `FloatingActionBar` beside it already says WHICH some as an exact count
|
|
273
|
-
glyph was approximating a number that was on screen.
|
|
288
|
+
"some", and the `FloatingActionBar` beside it already says WHICH some as an exact count.
|
|
274
289
|
|
|
275
290
|
While a mode is ON, the heading row holds only that mode's controls; the section's own ADD stands
|
|
276
291
|
down until it is off. Otherwise a `primary` add sits in a row that is entirely about the
|
|
277
292
|
selection, competing with the promoted act in the action bar — two accents on one screen. At rest
|
|
278
|
-
the add returns to the right edge where the law puts it, and drag/paste
|
|
279
|
-
no path actually closes.
|
|
293
|
+
the add returns to the right edge where the law puts it, and drag/paste keep working throughout.
|
|
280
294
|
|
|
281
295
|
- **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
|
|
282
296
|
`primary`. Don't drop to `secondary` because the list is full — an add that changes weight
|
|
@@ -295,9 +309,9 @@ screen already computes, and it is the copy that goes stale — the band re-coun
|
|
|
295
309
|
and the string does not.
|
|
296
310
|
|
|
297
311
|
It also decides where the count may live. A register whose bands already state their sizes must
|
|
298
|
-
not carry a strip of chips above it repeating the same three numbers; that is one fact twice
|
|
299
|
-
|
|
300
|
-
|
|
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.
|
|
301
315
|
|
|
302
316
|
## Period filters for time-constrained data
|
|
303
317
|
|
|
@@ -351,17 +365,16 @@ by WHAT THE SURFACE IS; never a raw `Pressable`.**
|
|
|
351
365
|
|
|
352
366
|
| Surface | Examples | Resting signature | Hover | Base |
|
|
353
367
|
|---|---|---|---|---|
|
|
354
|
-
| 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` |
|
|
355
369
|
| Actions | `Button`, `IconButton` | a FILL | darkens the fill (own color logic) — never a border, never a wash | built-in |
|
|
356
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` |
|
|
357
371
|
| Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
|
|
358
372
|
|
|
359
373
|
**ONE signal per species, and it has to be strong enough to carry alone.** `HOVER_BORDER` is
|
|
360
|
-
zinc-500, not 400
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
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.
|
|
365
378
|
|
|
366
379
|
**A themeable control derives EVERY state from its token — rest, hover, press, disabled.** The
|
|
367
380
|
failure is a control that resolves `colors.primary` correctly at rest and then paints a fixed
|
|
@@ -369,39 +382,35 @@ neighbouring zinc the moment it is touched, so a branded button turns grey under
|
|
|
369
382
|
for an OPAQUE overlay especially: a `backgroundImage` gradient drawn over a themed
|
|
370
383
|
`backgroundColor` hides it entirely while every token in the source reads correct. On web a token
|
|
371
384
|
is a `var()`, so "the same colour, lighter" cannot be computed at the call site — use a translucent
|
|
372
|
-
white/black overlay
|
|
385
|
+
white/black overlay.
|
|
373
386
|
|
|
374
387
|
**A control changes PAINT across its states and never GEOMETRY** — and every property it paints
|
|
375
388
|
has exactly ONE owner. Text that shifts on focus, a border that thickens, a ring drawn twice, a
|
|
376
389
|
tint applied on one path only: each is two pieces of code owning one property and happening to
|
|
377
|
-
agree at rest.
|
|
378
|
-
|
|
379
|
-
shared surface helper, a single frame kept across the view→edit swap — never to nudge the second
|
|
380
|
-
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.
|
|
381
392
|
|
|
382
393
|
**A control riding a hoverable surface must be DARKER than that surface** — and you have to hover
|
|
383
394
|
BOTH to see whether it is, because reaching the control means crossing its host. Equal washes
|
|
384
395
|
vanish at exactly the moment they are pointed at; a control LIGHTER than its host reads as a hole
|
|
385
|
-
punched in the row rather than as a target sitting on it. Neither failure is visible at rest,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
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.
|
|
390
400
|
|
|
391
|
-
**A SELECTED control needs a
|
|
392
|
-
|
|
393
|
-
compete
|
|
394
|
-
|
|
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.
|
|
395
406
|
|
|
396
407
|
For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
|
|
397
408
|
open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
|
|
398
|
-
is load-bearing
|
|
399
|
-
|
|
400
|
-
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.
|
|
401
411
|
Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
|
|
402
412
|
grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
|
|
403
|
-
greys *and* animates its edge while its siblings only border-hover.
|
|
404
|
-
on a bordered/input control and you've made it.
|
|
413
|
+
greys *and* animates its edge while its siblings only border-hover.
|
|
405
414
|
|
|
406
415
|
## Cards — banded and composable
|
|
407
416
|
|
|
@@ -444,12 +453,11 @@ objects — hairlines between them blur into one run-on list.
|
|
|
444
453
|
|
|
445
454
|
**A summary goes BELOW the controls that produce it.** Its figures are the result of the toolbar —
|
|
446
455
|
they move every time the reader touches a filter — so the layout has to state that order: set the
|
|
447
|
-
query, then read what it returned.
|
|
448
|
-
and the numbers change behind the reader's eye while they are still choosing.
|
|
456
|
+
query, then read what it returned.
|
|
449
457
|
|
|
450
|
-
It is not the page band's subtitle either
|
|
451
|
-
|
|
452
|
-
|
|
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.
|
|
453
461
|
|
|
454
462
|
`SummaryLine` is `Summary`'s `Facts` row on its own — reach for the compound when the set has a
|
|
455
463
|
distribution worth drawing, and for the bare line when it is only a few aggregates. A
|
|
@@ -523,45 +531,42 @@ SECTION** is this same family with the heading rung composed inside it — `Sect
|
|
|
523
531
|
collapsible section composes identically on both surfaces. There is no separate collapsible-section
|
|
524
532
|
component: one heading family and one disclosure across static + collapsible. The disclosure
|
|
525
533
|
header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
|
|
526
|
-
"More", "Options") wrapped around one named section
|
|
527
|
-
leaves the reader no reason to open the chevron.
|
|
534
|
+
"More", "Options") wrapped around one named section.
|
|
528
535
|
|
|
529
536
|
### A control's resting treatment promises what pressing it does
|
|
530
537
|
|
|
531
|
-
Both halves of that fail in practice, and they fail in opposite directions.
|
|
532
|
-
|
|
533
538
|
**A control that looks like text.** A press target whose resting state is a muted caption is
|
|
534
|
-
announced to a screen reader, revealed on hover, and invisible to everyone else
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
whole point. Rendering them the same is not neutrality — it is the promise being made to nobody, or
|
|
546
|
-
to everybody, and both are wrong. When a component takes an optional handler, its resting
|
|
547
|
-
appearance changes with the handler, never only its `accessibilityRole`.
|
|
548
|
-
|
|
549
|
-
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
|
|
550
550
|
what the element IS, and this rule is about what it LOOKS LIKE.
|
|
551
551
|
|
|
552
552
|
## No dead rows — registers & tables
|
|
553
553
|
|
|
554
554
|
**The identifier leads.** A register's FIRST column is the thing the reader came to find the row
|
|
555
555
|
by — its key, code, or id — because that is what they are scanning against a document, an email,
|
|
556
|
-
or someone on the phone.
|
|
557
|
-
|
|
556
|
+
or someone on the phone. No other column can take its place: a name repeats, a status is shared by
|
|
557
|
+
dozens.
|
|
558
558
|
|
|
559
559
|
Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
|
|
560
560
|
(sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
|
|
561
561
|
read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
|
|
562
562
|
`ActionMenu` (⋯ → `MenuButton` items: destructive last + **confirmed** via `Alert`). The whole
|
|
563
563
|
surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
|
|
564
|
-
|
|
564
|
+
separated by a 4px gap and nothing else.
|
|
565
|
+
|
|
566
|
+
**How MUCH the row reveals decides expand-vs-drawer.** An expanding row is for a few facts the
|
|
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.
|
|
565
570
|
|
|
566
571
|
| `PressableRow` variant | Shape | Use |
|
|
567
572
|
|---|---|---|
|
|
@@ -582,36 +587,33 @@ STATE (the count and Clear — what is picked, and how to un-pick it) from the A
|
|
|
582
587
|
(`children`) with the section boundary getting the most air (20) and each group sitting on the
|
|
583
588
|
action gap (8). Spaced uniformly, four controls read as four equal things, and the emphasis
|
|
584
589
|
ladder between them — muted for a dismiss, `danger` for a destructive act, `primary` for the
|
|
585
|
-
promoted one — stops reading as a ladder
|
|
586
|
-
|
|
587
|
-
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.
|
|
588
592
|
|
|
589
|
-
The grouping pays a second time at PHONE width
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
do with it) instead of orphaning one verb from the ladder it belongs to. A row of five peers has
|
|
594
|
-
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.
|
|
595
597
|
|
|
596
598
|
**Two exceptions, both real.** A self-contained OVERLAY — a menu, a selector's popover
|
|
597
599
|
(`MenuListItem`, `OptionList`) — has nothing outside itself to align with, so its rows keep
|
|
598
600
|
plain padding; bleeding there would push them past the popover's own edge. And a slot that
|
|
599
|
-
holds another component's PART takes that component's constant, never a hand-picked number
|
|
600
|
-
`AgentRun`'s dot column was 18 while the `Marker` it holds is `NODE` (24), so a 24px dot
|
|
601
|
-
centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
|
|
602
|
-
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.
|
|
603
602
|
|
|
604
603
|
**An indent is legible only when something VISIBLE occupies it**
|
|
605
604
|
— a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
|
|
606
605
|
compensation it forces spreads to every new surface that sits beside it.
|
|
607
606
|
|
|
608
|
-
|
|
609
|
-
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.
|
|
610
610
|
|
|
611
611
|
**The COLUMNAR register is `Table`/`TableRow`/`TableCell`**: define `columns` once — the header
|
|
612
|
-
band + every row's cell widths derive from it and can't drift. ONE hairline under the header
|
|
613
|
-
|
|
614
|
-
|
|
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.
|
|
615
617
|
A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash) —
|
|
616
618
|
so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
|
|
617
619
|
registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
|
|
@@ -624,12 +626,11 @@ Uniform ROW heights hide it completely — the rows measure identical, the conte
|
|
|
624
626
|
not — and the drift is against the row's own centred chrome (ordinal, checkbox) as much as
|
|
625
627
|
against the neighbouring columns.
|
|
626
628
|
|
|
627
|
-
**It takes a height FLOOR to bite
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
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.
|
|
633
634
|
|
|
634
635
|
**"Fixed" means the same every row, NOT one line.** A two-line cell is fine if it is two lines on
|
|
635
636
|
every row. And the cost is real: bounding a cell usually means `numberOfLines={1}` and
|
|
@@ -641,27 +642,24 @@ content off the register entirely.
|
|
|
641
642
|
ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
|
|
642
643
|
a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
|
|
643
644
|
block moves it. A feed is where this actually bites, because a feed is the one row primitive
|
|
644
|
-
whose text is written by the USER. Bound the text
|
|
645
|
-
|
|
646
|
-
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.
|
|
647
647
|
|
|
648
648
|
**Bounding the text is half the fix; the other half is aligning to the LINE, not the block.** A
|
|
649
649
|
clamp turns an 80px drift into a 10px one — it does not remove it, because a two-line label
|
|
650
650
|
still centres its ornaments half a line low while the one-line rows beside it do not. So pin
|
|
651
651
|
every ornament in the band to the first line's centre, from ONE constant the row derives (see
|
|
652
652
|
`FIRST_LINE_CENTRE` in `timeline.tsx`, the reference): the mark, the affordance, and whatever a
|
|
653
|
-
caller passes as trailing content all read it
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
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.
|
|
658
657
|
|
|
659
658
|
The way it gets in is a SLOT. A component that takes a caller's string or node cannot assume
|
|
660
|
-
anything about it, and every fixture
|
|
661
|
-
|
|
662
|
-
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.
|
|
663
661
|
|
|
664
|
-
Two corollaries
|
|
662
|
+
Two corollaries:
|
|
665
663
|
|
|
666
664
|
- **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
|
|
667
665
|
cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
|
|
@@ -679,13 +677,16 @@ freely when something FIXED occupies its top — a meter, a thumbnail — becaus
|
|
|
679
677
|
then still lands near the row's centre, where the chrome already is. `tpl_item_list`'s identity
|
|
680
678
|
cell is the worked example: name on top, the supporting values UNCONDITIONALLY beneath.
|
|
681
679
|
|
|
682
|
-
**A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
cell
|
|
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`.
|
|
684
|
+
|
|
685
|
+
**A pressable cell's target is the whole CELL, not the text inside it.** Bind the press and the
|
|
686
|
+
hover to the cell box, so the reader aims at a column rather than at a word. Wrapping only the name
|
|
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.
|
|
689
690
|
|
|
690
691
|
**Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
|
|
691
692
|
when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
|
|
@@ -696,20 +697,17 @@ instrument once its track is long enough to be a rule across the row.
|
|
|
696
697
|
|
|
697
698
|
**The FIXED columns' widths are the flexible one's budget — so audit them by what they CARRY,
|
|
698
699
|
not by what they are called.** With one `flex` column among N fixed, every pixel a fixed column
|
|
699
|
-
takes comes out of the flexible one, silently
|
|
700
|
-
the
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
widest CELL content per column; where a column's width is set by its label rather than its
|
|
705
|
-
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.
|
|
706
705
|
|
|
707
706
|
**Space by the RATIO of within-group to between-group, never by absolutes.** Collect the gap
|
|
708
707
|
inside a composed band and the gap between that band and its neighbours, and divide. Near 1 is
|
|
709
|
-
the defect however sensible each number is alone
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
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.
|
|
713
711
|
|
|
714
712
|
Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
|
|
715
713
|
`CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
|
|
@@ -721,29 +719,25 @@ only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selecta
|
|
|
721
719
|
disabled checkbox, keeping the gutter aligned). Selection persists across pages, so `count` is the
|
|
722
720
|
running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination`).
|
|
723
721
|
|
|
724
|
-
**A paginated register owes the reader a scroll reset, and it is not the pager's to give.**
|
|
725
|
-
the
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
`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.
|
|
731
728
|
|
|
732
729
|
**ONE pager, ABOVE the rows.** Once the reset returns the viewport to the top on every press, the
|
|
733
730
|
top is where the reader's cursor already is. A second copy under the rows is the same state said
|
|
734
|
-
twice
|
|
731
|
+
twice.
|
|
735
732
|
|
|
736
733
|
**Put it on the `SummaryLine` row, not a row of its own** — summary at `flex: 1`, pager sized to its
|
|
737
734
|
content, the row aligned `flex-start` so the arrows stay on the summary's FIRST line when it wraps.
|
|
738
735
|
Both describe the window the toolbar just set (what matched, and which slice is on screen), so they
|
|
739
|
-
are one line. A pager given its own row is a small cluster against a mostly empty band
|
|
740
|
-
before believing the row is needed; a four-item summary and a full pager leave a quarter of a
|
|
741
|
-
1000px band spare.
|
|
736
|
+
are one line. A pager given its own row is a small cluster against a mostly empty band.
|
|
742
737
|
|
|
743
738
|
Never hand `Pagination` the full width with an empty left slot. It gives its range `flex: 1` so a
|
|
744
739
|
band whose left slot HOLDS something pushes the arrows to the far edge; with nothing there, that
|
|
745
|
-
same rule strands the range hundreds of pixels from the arrows it belongs to
|
|
746
|
-
control reading as two unrelated things on a line.
|
|
740
|
+
same rule strands the range hundreds of pixels from the arrows it belongs to.
|
|
747
741
|
|
|
748
742
|
**The pager's range IS the register's count** (`16–30 of 45`), so pair it with `Table` `counted`,
|
|
749
743
|
never `count` — the latter prints the matched total again a few rows below, and one number in two
|
|
@@ -752,8 +746,7 @@ places is two numbers that can disagree. Row totals beside the pager go the same
|
|
|
752
746
|
|
|
753
747
|
There is deliberately no page index. `Page 2 of 3` beside `16–30 of 45` answers the same question a
|
|
754
748
|
second way and enables no decision the range does not — how much is left is in the total, whether
|
|
755
|
-
this is the end is in the disabled arrow.
|
|
756
|
-
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.
|
|
757
750
|
|
|
758
751
|
Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
|
|
759
752
|
a fixed `width`, so amount/status columns don't jitter.
|
|
@@ -781,9 +774,7 @@ non-interactive by construction. `TableRow` already composes `PressDoor`.
|
|
|
781
774
|
chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
|
|
782
775
|
siblings the eye reads one cluster. Put clearly
|
|
783
776
|
more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
|
|
784
|
-
divider, a weight change or any new element.
|
|
785
|
-
makes every child a peer — applied to a row's right edge, where it is easy to miss because both
|
|
786
|
-
items are small grey glyphs sitting on the same line.
|
|
777
|
+
divider, a weight change or any new element.
|
|
787
778
|
|
|
788
779
|
**"Non-interactive by construction" excludes any SLOT.** A trailing `right` / `actions` / `meta`
|
|
789
780
|
prop takes whatever the caller passes, so a row that wraps one in its own press surface is a
|
|
@@ -800,13 +791,11 @@ so it's still keyboard-reachable. Do that locally — there is deliberately no s
|
|
|
800
791
|
primitive.
|
|
801
792
|
|
|
802
793
|
**A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
|
|
803
|
-
The surface carries no blanket `userSelect: "none"` —
|
|
804
|
-
|
|
805
|
-
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
|
|
806
796
|
surfaces consult `pressSelectedText()` instead, so a release that ENDED a selection doesn't also
|
|
807
797
|
open the record and throw that selection away. Only a real, non-empty selection suppresses: a
|
|
808
|
-
swallowed ordinary click would leave the row impossible to open
|
|
809
|
-
this fixes.
|
|
798
|
+
swallowed ordinary click would leave the row impossible to open.
|
|
810
799
|
|
|
811
800
|
Selection covers every value at once, so reach for **`CopyButton`** only where one value is copied
|
|
812
801
|
often enough to earn a control (a phone number a rep dials, a reference quoted into a message) —
|
|
@@ -833,34 +822,29 @@ Identity is the handful of facts someone would say aloud to name this record (it
|
|
|
833
822
|
counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
|
|
834
823
|
reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
|
|
835
824
|
labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
|
|
836
|
-
that leaves a reader who cannot tell whose record they are in.
|
|
837
|
-
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
|
|
838
826
|
answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
|
|
839
827
|
fact can still be the one that names the thing.
|
|
840
828
|
|
|
841
829
|
"The chrome already carries identity, so the body need not lead with it" is a real argument and a
|
|
842
830
|
falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
|
|
843
|
-
number and its totals carries half an identity; the counterparty is missing
|
|
844
|
-
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.
|
|
845
832
|
|
|
846
833
|
**All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
|
|
847
834
|
stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
|
|
848
835
|
never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
|
|
849
|
-
record page they are already in it
|
|
850
|
-
|
|
851
|
-
pick the rule — for a ledger it is ONE at a time, because two open details push the next row off
|
|
852
|
-
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
|
|
853
838
|
weight, not its field count: read or light-edit (independent inline commits) expands, a real form
|
|
854
839
|
with a submit and cross-field validation still earns its own surface.
|
|
855
840
|
|
|
856
841
|
The expansion carries **EVERY** field, including the ones already in columns. Column cells are
|
|
857
842
|
read-only, and `table_fit` tier 2 drops droppable columns on the explicit promise that a door
|
|
858
|
-
reaches the dropped values — the expansion IS that door, so anything it omits is unreachable
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
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.
|
|
864
848
|
|
|
865
849
|
## Two work shapes, no side panels
|
|
866
850
|
|
|
@@ -902,12 +886,11 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
902
886
|
content is TEXT: a line is ~20, so `minHeight` governs and the padding never binds — which is
|
|
903
887
|
why a surface can pick any inset it likes and nothing on a resting screen ever contradicts it.
|
|
904
888
|
The moment something with a FIXED height goes in — an avatar, a swatch, a thumbnail — that
|
|
905
|
-
inset decides the control's whole height
|
|
906
|
-
(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
|
|
907
890
|
the band's budget, never against how it looks alone, and a surface that needs more room than
|
|
908
891
|
the budget is not a control. The budget is 28 because that is what `InlineButton` and a `md`
|
|
909
|
-
`IconButton`
|
|
910
|
-
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.
|
|
911
894
|
|
|
912
895
|
- **`SearchInput`** is THE search box — a white `TextInputField` preset (the shared
|
|
913
896
|
`CONTROL_RADIUS` like every other input, a thin border, the leading search glyph + a clear ✕).
|
|
@@ -945,8 +928,8 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
945
928
|
- **A form's COMMIT sits with its fields, never in the footer.** The button submitting a set of
|
|
946
929
|
inputs goes directly beneath the last one it commits, `alignSelf="flex-start"` — the same left
|
|
947
930
|
edge the user just filled (data_entry.md's form-action alignment law, which holds inside an
|
|
948
|
-
overlay too). A footer commit
|
|
949
|
-
|
|
931
|
+
overlay too). A footer commit reads as belonging to the dialog rather than the fields.
|
|
932
|
+
A **`Reset` travels with the `Save` it
|
|
950
933
|
undoes** — same draft, same row, secondary to the commit's left; splitting the pair across two
|
|
951
934
|
surfaces breaks one decision in half. A dialog whose only actions are a commit and its reset has
|
|
952
935
|
**no footer at all**.
|
|
@@ -956,11 +939,10 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
956
939
|
at the right edge, secondary to its left. A confirm dialog (no fields) is all surface: its
|
|
957
940
|
buttons ARE the dialog, and they belong in the footer.
|
|
958
941
|
- **A full-page editor is not a dialog.** Where the body SCROLLS and one commit governs the whole
|
|
959
|
-
page, that commit stays pinned at the page's foot:
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
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.
|
|
964
946
|
- **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
|
|
965
947
|
- **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
|
|
966
948
|
Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
|
|
@@ -981,8 +963,7 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
981
963
|
carries a ground (red-100) exactly where `secondary` carries zinc-100, so every act in the row
|
|
982
964
|
keeps ONE shape and only the ink moves; the ladder is then legible as a ladder. Drop a
|
|
983
965
|
groundless `danger-secondary` in there and the destructive verb is the one control with no box
|
|
984
|
-
— a shape difference, which a reader parses before colour
|
|
985
|
-
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.
|
|
986
967
|
Among TEXT — a row's trailing verb, a `Callout`'s action, a dialog's tertiary — use
|
|
987
968
|
`danger-secondary`, because there a filled box is the thing that looks pasted on. A
|
|
988
969
|
fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
|
|
@@ -994,21 +975,16 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
994
975
|
`InlineButton` (28px, filled, on a field's own surface).
|
|
995
976
|
|
|
996
977
|
**AN ACT CARRIES A CONTROL; UNDERLINED TEXT REVEALS.** `Link`/`TextLink` are the underlined
|
|
997
|
-
pair; everything that MUTATES is a `Button` or an `InlineButton`.
|
|
998
|
-
|
|
999
|
-
stopped answering the one question a reader has — does this take me away, or does it do
|
|
1000
|
-
something here. It also competed with the `Button` colour ladder, since `muted` already IS the
|
|
1001
|
-
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.
|
|
1002
980
|
|
|
1003
981
|
**The ONE exception, and it is narrow: an IN-PROSE DISCLOSURE.** A show/hide for adjacent
|
|
1004
982
|
content — a transcript under its player, a long value under its summary — has nowhere else to
|
|
1005
|
-
go. It cannot move to chrome, because it belongs to the thing above it
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
a
|
|
1009
|
-
|
|
1010
|
-
full-width header with its own chevron, and nesting one inside a row that already has a chevron
|
|
1011
|
-
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.
|
|
1012
988
|
|
|
1013
989
|
So underline is permitted here, and the INK keeps it unambiguous:
|
|
1014
990
|
|
|
@@ -1017,13 +993,11 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
1017
993
|
| blue + `role="link"` (`Link`) | leaves this surface |
|
|
1018
994
|
| muted underline in a caller's `Pressable` (`TextLink`, no `href`) | reveals more of it, in place |
|
|
1019
995
|
|
|
1020
|
-
Both say "show me more"; only one takes you away
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
the
|
|
1025
|
-
"success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
|
|
1026
|
-
(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.
|
|
1027
1001
|
- **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
|
|
1028
1002
|
rides its section's HEADING row at the right edge — see § The add-placement law for why, and
|
|
1029
1003
|
for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
|
|
@@ -1074,10 +1048,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1074
1048
|
should be is decided per table — see data_entry.md §The editability affordance.
|
|
1075
1049
|
- **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
|
|
1076
1050
|
this reference, call this number, open this record: the act names the value, so it travels
|
|
1077
|
-
with what it acts on
|
|
1078
|
-
|
|
1079
|
-
column is paid by every row for the benefit of one — it was declared on the TABLE, so a
|
|
1080
|
-
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*`
|
|
1081
1053
|
editor takes `actions`, popover-backed ones included — a field exposes its outer box
|
|
1082
1054
|
separately from the control inside it, so a select's list and a date's calendar still open at
|
|
1083
1055
|
the field's own width. Anything that is not a verb — a status `Badge`, a unit — composes into
|
|
@@ -1088,22 +1060,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1088
1060
|
editor promises never to do.
|
|
1089
1061
|
- **What is about the WHOLE record is a PANEL, not a section.** A section takes a position in a
|
|
1090
1062
|
top-to-bottom reading order, which is a claim that it belongs at that point in the read. A
|
|
1091
|
-
record's DISCUSSION does not: a note is written about anything on the page
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
discussion is docked precisely because a thing "parked at one position is unreachable from
|
|
1101
|
-
every other". Routing the sections makes that true of every one of them. It is tempting on a
|
|
1102
|
-
long record and it does not survive contact: each fix it needs rebuilds the whole-record view
|
|
1103
|
-
in miniature — a dot to say which hidden section just changed, a page-band action to
|
|
1104
|
-
re-surface an affordance some section had swallowed, an "everything" route that IS this page.
|
|
1105
|
-
It also HIDES what it does not carry: a whole-record drop target advertised only inside the
|
|
1106
|
-
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
|
|
1107
1072
|
permanently — browser find reaches only the open section, two sections can never be read
|
|
1108
1073
|
together, and every cross-section check costs a click each way. A long record is a DENSITY
|
|
1109
1074
|
problem, and density is cuttable (a choice that took four rows, a group that restated the
|
|
@@ -1118,8 +1083,7 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1118
1083
|
`Transfer point` / `Destination` looks like three fields and is actually one route — it cannot
|
|
1119
1084
|
hold a second transfer, and three identical rows say nothing about which comes first. The same
|
|
1120
1085
|
goes for an approval chain, a set of legs, a list of signatories. Rendered as a `Sequence` it
|
|
1121
|
-
gains the one thing the grid could never give it — visible ORDER
|
|
1122
|
-
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.
|
|
1123
1087
|
Nothing about spacing or disclosure fixes a section holding the wrong SHAPE, and a section that
|
|
1124
1088
|
looks "unattended" is usually this rather than a density problem.
|
|
1125
1089
|
- **A VERB GOES WHERE ITS EFFECT LANDS.** One law places every action on a record surface, and
|
|
@@ -1134,19 +1098,16 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1134
1098
|
| the record's EXISTENCE | the `DangerZone`, last on the page |
|
|
1135
1099
|
|
|
1136
1100
|
The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
|
|
1137
|
-
says why
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
verb. **An add is `primary` wherever it renders** — on that heading row, or inside an
|
|
1141
|
-
`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
|
|
1142
1104
|
is the act its section offers, so it carries that section's weight; a lone `secondary` button
|
|
1143
1105
|
reads as though the real action were somewhere else. The rule that matters is that ONE VERB
|
|
1144
1106
|
KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
|
|
1145
1107
|
A record surface therefore carries several primaries, one per section, and that is correct:
|
|
1146
1108
|
"one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
|
|
1147
|
-
its own single act. The
|
|
1148
|
-
|
|
1149
|
-
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.
|
|
1150
1111
|
- **A DENSE section is compacted by a SUMMARY above it, by ROW HEIGHT, and by grouping fields
|
|
1151
1112
|
that are ONE ENTITY — never by a two-column layout.** The evidence is unusually clear here, so
|
|
1152
1113
|
do not re-derive it: Baymard finds an overall two-column form layout "generally problematic and
|
|
@@ -1161,14 +1122,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1161
1122
|
name its parts by adding a sub-label line no other row has — which drops its controls off the
|
|
1162
1123
|
baseline every other row shares — and naming them with placeholders loses the names the moment
|
|
1163
1124
|
the row is filled. In a label-left grid, ONE FIELD PER ROW is not a failure to compact; it is
|
|
1164
|
-
the only shape that keeps the column a reader scans.
|
|
1165
|
-
re-runs them.
|
|
1125
|
+
the only shape that keeps the column a reader scans.
|
|
1166
1126
|
**A spread `DetailRow` gives its VALUE the squeeze, never its label.** The two share one flex
|
|
1167
1127
|
row, so whichever of them cannot shrink forces the other to. A flex child defaults to
|
|
1168
1128
|
`min-width: auto`, which floors the value at its longest word — and the label, having no floor
|
|
1169
|
-
of its own, absorbs everything left
|
|
1170
|
-
|
|
1171
|
-
|
|
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**:
|
|
1172
1132
|
wherever a fixed-ish label shares a row with a `flex: 1` value, measure the LABEL's width and
|
|
1173
1133
|
height across rows whose values differ in length — a label whose box changes shape from row to
|
|
1174
1134
|
row is the signature, and it is invisible at any width where every value happens to fit.
|
|
@@ -1176,9 +1136,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1176
1136
|
required field, while a record page is read-mostly.
|
|
1177
1137
|
A SUMMARY belongs to the RECORD, not to a section. The highlights-panel precedent (a strip of
|
|
1178
1138
|
key facts above the detail) is a PAGE-level device, where what it summarises is a scroll or a
|
|
1179
|
-
tab away; repeated per section it sits inches above the very rows it repeats
|
|
1180
|
-
|
|
1181
|
-
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.
|
|
1182
1141
|
A read-only view behind an Edit button is the last resort: it compacts by DROPPING empty
|
|
1183
1142
|
fields — and an empty row is information, the difference between "this record has no vehicle"
|
|
1184
1143
|
and "this section has no vehicle field" — and it costs a MODE, against the law that the surface
|
|
@@ -1186,9 +1145,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1186
1145
|
- **A REFERENCE to another record is a FIELD, not a section and not a card.** The kit already
|
|
1187
1146
|
draws every other reference as a token — a person is a `MemberChip`, a file a `FileBadge`, a
|
|
1188
1147
|
select option an `OptionBadge` — and a record is no different. It renders as a VALUE wearing
|
|
1189
|
-
the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius)
|
|
1190
|
-
|
|
1191
|
-
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
|
|
1192
1150
|
code beside a link reads as an orphan.
|
|
1193
1151
|
**ONE act on the surface, the rest one layer in.** Pressing the field PEEKS the facts — free,
|
|
1194
1152
|
so it takes the whole box as its target — and every verb lives INSIDE the peek, placed by
|
|
@@ -1199,21 +1157,18 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1199
1157
|
Change is `secondary` and NOT `danger` — a correction, not a destruction — Edit takes the one
|
|
1200
1158
|
filled-dark rung and hands it to Save, Open stays `secondary` and never prioritized. None of
|
|
1201
1159
|
them sits on the field. After Change, hand the picker focus; after Clear, never — the two
|
|
1202
|
-
verbs land in the same place meaning opposite things. An interior verb
|
|
1203
|
-
(press the box → a summary; press 8px right → navigate),
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
ABOUT the value — leaving is the rare move on a record page. Without it the field has its plain
|
|
1207
|
-
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.
|
|
1208
1164
|
Detach earns its depth twice — destructive and rare, and it ANSWERS the question the peek asks
|
|
1209
1165
|
("is this the right one?"), so the check and the correction are one gesture. A detach link
|
|
1210
1166
|
repeated down a column of references is noise for an act most readers never perform.
|
|
1211
1167
|
**Open is OPTIONAL, and Edit is why.** A reference whose facts can be corrected in the peek has
|
|
1212
|
-
no errand to send the reader on
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
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.
|
|
1217
1172
|
**Never promote it to a Section.** A section claims a distinct AREA of the record and buys an
|
|
1218
1173
|
outline-rail entry; one pointer at one other row never earns that. What tempts the promotion is
|
|
1219
1174
|
the machinery around it — and each piece is just part of the field: the search is its EMPTY
|
|
@@ -1248,9 +1203,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1248
1203
|
- **A PERSON token peeks the same way a record reference does — but only where nothing else
|
|
1249
1204
|
on the line presses.** `MemberPeek` makes a `MemberChip` a door onto `MemberProfileCard`
|
|
1250
1205
|
(a `xl` avatar, the sign-in identity, role, teams), and it is the same drill-down grammar:
|
|
1251
|
-
identity on the surface, the facts one layer in. The constraint is where it may live
|
|
1252
|
-
|
|
1253
|
-
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
|
|
1254
1208
|
and both keep the plain chip — a PICKER OPTION (a card over the open menu, fired at the one
|
|
1255
1209
|
moment the reader is choosing rather than browsing) and a ROW THAT NAVIGATES (press the row,
|
|
1256
1210
|
open the record; press four pixels left, open a card). What is left is where a peek is
|
|
@@ -1260,10 +1214,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1260
1214
|
the drill-down.
|
|
1261
1215
|
**Judge the CONTAINER, not how inert it looks.** A grouped register's band header names a
|
|
1262
1216
|
person, carries no visible control, and is a button — it collapses the group — so a token
|
|
1263
|
-
inside it nests a button in a button and splits one gesture in two.
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
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.
|
|
1267
1220
|
**A profile's rows are conditional, and absent is not empty**: a field nobody supplied has
|
|
1268
1221
|
no row, while a field supplied as EMPTY keeps its row and says so. Collapsing the two turns
|
|
1269
1222
|
"we were not told" into a confident claim about a colleague — the same distinction a
|
|
@@ -1277,8 +1230,7 @@ checkbox, a switch, a chip. The line is not "is it pressable" (nearly everything
|
|
|
1277
1230
|
"does pressing it DO something, or does it set a value / open the thing you are already
|
|
1278
1231
|
looking at".
|
|
1279
1232
|
|
|
1280
|
-
Drawn tightly because a signal on half the screen is not a signal.
|
|
1281
|
-
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
|
|
1282
1234
|
press this" elsewhere is the hover treatment the surface already owns — a row's wash, a
|
|
1283
1235
|
field's border, a chip's fill — which is what a native list or menu uses too, where the
|
|
1284
1236
|
cursor stays an arrow throughout.
|
|
@@ -1304,31 +1256,45 @@ ask for one.
|
|
|
1304
1256
|
**WHAT a line separates is the whole test — not how big the break feels.** A rule between two rows
|
|
1305
1257
|
divides LIKE FROM LIKE, and the space between them already says that. The band's rule divides
|
|
1306
1258
|
CHROME from CONTENT — a row of labels from the records they name — and no amount of whitespace
|
|
1307
|
-
states that
|
|
1308
|
-
|
|
1309
|
-
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.
|
|
1310
1261
|
|
|
1311
1262
|
Drawing both makes them compete, and a grid of equal lines is most of what "it looks like a
|
|
1312
1263
|
spreadsheet" means.
|
|
1313
1264
|
|
|
1314
1265
|
**The hover wash does the work the rule did**, and it already has the shape: `PressableRow` bleeds
|
|
1315
1266
|
its ground past the content and rounds it, so a register reads as a stack of cards with no card
|
|
1316
|
-
component involved.
|
|
1317
|
-
|
|
1318
|
-
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.
|
|
1319
1269
|
|
|
1320
1270
|
**Density is a number, never a mode.** A surface that genuinely needs more rows on screen sets
|
|
1321
|
-
`minHeight` on the row. What no caller can ask for is the rules back —
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
rhythms used to express already lived at the component boundary; offering it again as a prop was a
|
|
1325
|
-
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.
|
|
1326
1274
|
|
|
1327
1275
|
## A register that TRIAGES — `TableGroup`
|
|
1328
1276
|
|
|
1329
1277
|
A flat register sorted by a column hands the reader every row at equal weight and leaves the
|
|
1330
|
-
deciding to them
|
|
1331
|
-
|
|
1278
|
+
deciding to them. `TableGroup` moves that decision into the screen — a titled band of rows with a
|
|
1279
|
+
count.
|
|
1280
|
+
|
|
1281
|
+
**Flat is the default; grouping is something the reader turns ON.** A register opens as one
|
|
1282
|
+
continuous list, and the control that splits it into bands lives in the filter band beside the
|
|
1283
|
+
other view controls — an explicit "group by" the reader operates, defaulting to none. Authored
|
|
1284
|
+
bands fail three ways at once: the value they group on is unsortable, it is unfilterable, and it
|
|
1285
|
+
vanishes the moment the reader wants a different cut — so the screen holds an opinion the reader
|
|
1286
|
+
cannot inspect, disagree with, or put down.
|
|
1287
|
+
|
|
1288
|
+
**The status COLUMN is the floor.** Whatever the register can group on also exists as a column the
|
|
1289
|
+
reader can see, sort and filter — the column is the fact, the grouping is one way of looking at it.
|
|
1290
|
+
Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
|
|
1291
|
+
picked. Everything below governs how the bands look and behave once they are on.
|
|
1292
|
+
|
|
1293
|
+
**A value's filter renders the value the same way its cell does.** If the status cell is a
|
|
1294
|
+
`Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
|
|
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.
|
|
1332
1298
|
|
|
1333
1299
|
**Group by what implies a different ACTION, never by a category the reader can already see.**
|
|
1334
1300
|
Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
|
|
@@ -1356,64 +1322,55 @@ column is a sort wearing chrome: it spends a band per value to repeat the cell b
|
|
|
1356
1322
|
the archived ones — and the tempting shortcut is to let the band list double as the vocabulary,
|
|
1357
1323
|
treating "matches no band" as "unrecognised". It breaks in both directions at once. The values
|
|
1358
1324
|
you deliberately left out now match nothing, so they resurface under whatever heading catches
|
|
1359
|
-
the remainder — *Unclassified*, *Other
|
|
1360
|
-
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
|
|
1361
1326
|
value added to the field later silently disappears from the one screen that would have shown it.
|
|
1362
1327
|
Keep a set of values the screen can classify and a list of bands it renders: a value in the
|
|
1363
1328
|
first but not the second is omitted on purpose, and a value in neither gets a visible band,
|
|
1364
1329
|
because a register that quietly files what it does not understand is how a stale row stays
|
|
1365
1330
|
invisible. Both lists are ALLOWLISTS — `else` is where the second one goes wrong.
|
|
1366
1331
|
|
|
1367
|
-
Banding also changes what the COLUMNS have to carry
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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.
|
|
1371
1336
|
|
|
1372
1337
|
## The register's own craft — what "it looks like a spreadsheet" actually is
|
|
1373
1338
|
|
|
1374
1339
|
Three defects produce that report, none of them about colour, and all three survive a design
|
|
1375
|
-
audit.
|
|
1376
|
-
that one is settled above — a register has one line, and it is the band.)
|
|
1340
|
+
audit.
|
|
1377
1341
|
|
|
1378
1342
|
**A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
|
|
1379
1343
|
it heads in size and separating from them by weight and ink. It is not an eyebrow: an eyebrow is
|
|
1380
1344
|
`xs`, calibrated to sit quietly beside a value it captions, and a column header names a whole
|
|
1381
1345
|
column of them. Dropped to `xs` and lightened past `muted` it stops being legible and starts
|
|
1382
|
-
reading as a caption that lost its value
|
|
1383
|
-
|
|
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.
|
|
1384
1348
|
|
|
1385
1349
|
**A fixed column `width` is sized for the LONGEST locale, not the one you typed it in.** Every
|
|
1386
1350
|
header is a translated string, and the same label runs half again as long in Vietnamese as in
|
|
1387
1351
|
English — so a width measured against the source truncates the header in the language the app
|
|
1388
|
-
actually ships in
|
|
1389
|
-
|
|
1390
|
-
`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.
|
|
1391
1354
|
|
|
1392
1355
|
**Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
|
|
1393
|
-
the #1 templated tell
|
|
1394
|
-
|
|
1395
|
-
without replacing the separation is what left the header looking adrift — the ink step above is
|
|
1396
|
-
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.
|
|
1397
1358
|
|
|
1398
1359
|
**A LOOKUP KEY IS NOT A COLUMN — and this is where "cramped" usually comes from.** Ask of each
|
|
1399
1360
|
column whether its value is COMPARED down the page or MATCHED one at a time. A reference,
|
|
1400
1361
|
identifier or code that a reader arrives already holding — quoted on the phone, printed on a
|
|
1401
1362
|
document, pasted from an email — is matched, and the question it answers is "which record is this
|
|
1402
1363
|
one", which the search field answers exactly and without scanning. A column answers it by spending
|
|
1403
|
-
its width on every row forever, and takes that width from the columns that ARE compared.
|
|
1404
|
-
on
|
|
1405
|
-
carrying the facts a reader scans lost 224px and 84px of real values: cutting the references
|
|
1406
|
-
halved the truncation and returned the width to where the eye actually goes. Put them in the
|
|
1407
|
-
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
|
|
1408
1366
|
in a column, unless the value is genuinely compared (a sequence a reader reads for gaps, a code
|
|
1409
1367
|
whose PREFIX groups the rows).
|
|
1410
1368
|
|
|
1411
1369
|
**`Table` does not scroll horizontally, and that is the feature.** When the columns do not fit, it
|
|
1412
1370
|
sheds by `priority` and then stacks — it never offers a pan. A horizontal scroller is a WIDTH fix
|
|
1413
1371
|
for a COUNT problem: it makes room for one more column without ever asking whether the column
|
|
1414
|
-
earns a row's width, and the columns it hides off-screen are hidden from the author too
|
|
1415
|
-
|
|
1416
|
-
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 —
|
|
1417
1374
|
every column compared against its neighbours — is a different shape: a quantity across two
|
|
1418
1375
|
dimensions is `Matrix`, a managed grid is `DataGrid`.
|
|
1419
1376
|
|
|
@@ -1430,11 +1387,11 @@ outwards.
|
|
|
1430
1387
|
no row carries yet — that is data-driven shape, and it is allowed. What breaks is that the
|
|
1431
1388
|
`columns` entry and the row's `TableCell` are two edits in two files, so it is natural to make
|
|
1432
1389
|
only the first: the header band loses a heading, the row still renders the cell, and from there
|
|
1433
|
-
every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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.
|
|
1438
1395
|
|
|
1439
1396
|
**A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
|
|
1440
1397
|
`##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
|
|
@@ -1458,8 +1415,7 @@ recedes.
|
|
|
1458
1415
|
**The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
|
|
1459
1416
|
plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
|
|
1460
1417
|
often argues for a size step and should not get one: tabular figures and right alignment already
|
|
1461
|
-
mark it, and a lone larger value on a row reads as a mistake rather than as emphasis.
|
|
1462
|
-
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.
|
|
1463
1419
|
|
|
1464
1420
|
**Whether the SUPPORTING line drops a rung is decided by what else separates the pair — not by
|
|
1465
1421
|
where it sits and not by what it carries.** `color="muted"` is already one channel of demotion, so
|
|
@@ -1480,9 +1436,8 @@ pair's, not each half's.
|
|
|
1480
1436
|
**A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
|
|
1481
1437
|
leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
|
|
1482
1438
|
object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
|
|
1483
|
-
a 40px mark the
|
|
1484
|
-
|
|
1485
|
-
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.
|
|
1486
1441
|
|
|
1487
1442
|
Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
|
|
1488
1443
|
tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
|
|
@@ -1495,12 +1450,8 @@ Running text keeps prose leading. The default is for paragraphs, and `.ui-markdo
|
|
|
1495
1450
|
sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
|
|
1496
1451
|
the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
|
|
1497
1452
|
starts lower, and the pair reads with a wider gap than the identical pair one column over that
|
|
1498
|
-
happens to carry no control — one shared `gap` reading as two different ones.
|
|
1499
|
-
|
|
1500
|
-
It is invisible in review because every number is defensible — the gap is right, the rungs are
|
|
1501
|
-
right, the control is the documented size — and the defect is the interaction between them. It is
|
|
1502
|
-
also the harder half of *same shape, same treatment*: the reason for the difference is real, and a
|
|
1503
|
-
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.
|
|
1504
1455
|
|
|
1505
1456
|
Pin the row to the TEXT's line box (`height` = the rung's leading) and let the control overflow it.
|
|
1506
1457
|
The hit area is unchanged — it paints outside the box rather than growing it — and the text sits
|
|
@@ -1515,17 +1466,14 @@ where it would if the control were not there. The same applies to any verb ridin
|
|
|
1515
1466
|
**The shape varies only where the surface MIXES entity types** and the reader acts on which is
|
|
1516
1467
|
which: a search result holding both people and companies, a "related records" list, a picker over
|
|
1517
1468
|
two directories. On a surface whose rows are all one kind of thing — a register of records, a
|
|
1518
|
-
roster, a list of accounts — pick ONE shape and hold it.
|
|
1519
|
-
|
|
1520
|
-
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,
|
|
1521
1471
|
and asserting one the reader cannot use costs more than it gives.
|
|
1522
1472
|
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
twenty
|
|
1526
|
-
|
|
1527
|
-
person's disc asserts they are the same kind of entity, which the column header alone has to
|
|
1528
|
-
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.
|
|
1529
1477
|
|
|
1530
1478
|
- **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
|
|
1531
1479
|
the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
|
|
@@ -1540,16 +1488,16 @@ correct.
|
|
|
1540
1488
|
- **Every register row should carry a LEADING MARK, best effort from the data.** An avatar, a
|
|
1541
1489
|
logo, a thumbnail, a file badge, a coloured initial — something the eye can aim at without
|
|
1542
1490
|
reading. A register whose rows open with text starts flat, and the symptom is reported as
|
|
1543
|
-
anything but the cause ("that avatar looks too small", "it looks plain")
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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.
|
|
1548
1496
|
- **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
|
|
1549
1497
|
where it is one field among columns (see `avatar_size`).
|
|
1550
1498
|
- **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
|
|
1551
1499
|
can reach this", the commonest state is often *everyone* — and three sampled avatars assert
|
|
1552
|
-
something false about
|
|
1500
|
+
something false about it. Draw the
|
|
1553
1501
|
unbounded grant as a mark that SAYS so, and people only where the set is genuinely enumerable;
|
|
1554
1502
|
cap the faces at a few and let the remainder be a `+N` — as the last mark IN the stack, since a
|
|
1555
1503
|
number trailing after the discs reads as a separate fact about the row. Drop anyone the row
|
|
@@ -1558,23 +1506,19 @@ correct.
|
|
|
1558
1506
|
reader may not be entitled to. And the faces stay INERT inside a pressable row, per
|
|
1559
1507
|
member_peek.tsx.
|
|
1560
1508
|
- **An overlapping stack separates with translucent INK, never with the surface colour.** The
|
|
1561
|
-
conventional facepile rings each mark in the page background
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
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
|
|
1565
1512
|
translucent dark edge darkens whatever is behind it instead of matching it, so it holds on every
|
|
1566
1513
|
ground; `FileBadge` separates itself from its own surface the same way. Use a `boxShadow`, not a
|
|
1567
1514
|
border — a border grows the box, and the stacked mark then measures wider than the identical
|
|
1568
1515
|
mark in the next column.
|
|
1569
1516
|
|
|
1570
|
-
The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
|
|
1571
|
-
|
|
1572
1517
|
## Character comes from the DATA — the answer when a screen reads "bland"
|
|
1573
1518
|
|
|
1574
1519
|
Every rule above removes a defect. None of them ADDS anything, so a screen can pass all of them
|
|
1575
1520
|
and still be reported as plain, boring, or unfinished — which is a different complaint with a
|
|
1576
|
-
different fix.
|
|
1577
|
-
*distinctive*.
|
|
1521
|
+
different fix.
|
|
1578
1522
|
|
|
1579
1523
|
- **What is inherently visual in THIS data?** Work software has no photography, so the data itself
|
|
1580
1524
|
is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
|
|
@@ -1589,12 +1533,38 @@ different fix. Removing defects converges on *inoffensive*; these three question
|
|
|
1589
1533
|
keep not landing.
|
|
1590
1534
|
- **What makes this belong to THIS product?** Default typeface, default greys, default accent,
|
|
1591
1535
|
default control shapes is a *well-built generic screen*, and no further polish changes that. The
|
|
1592
|
-
fix at that point is a theme (`applyLoticsTheme`), not another treatment pass
|
|
1593
|
-
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.
|
|
1594
1537
|
|
|
1595
1538
|
Type SCALE is the fourth, and it belongs with the others: range = largest ÷ smallest across the
|
|
1596
|
-
screen, and under about 2× reads flat *at any amount of colour*.
|
|
1597
|
-
|
|
1539
|
+
screen, and under about 2× reads flat *at any amount of colour*.
|
|
1540
|
+
|
|
1541
|
+
**The test every visual has to pass: does it make the screen CLEARER *and* better-looking?** Both,
|
|
1542
|
+
not either. A visual that only decorates is clutter and goes; a visual that only informs but fights
|
|
1543
|
+
the composition is a chart nobody reads. The ones that pay are the ones where the two are the same
|
|
1544
|
+
act — a status colour that is also the scan path, a product thumbnail that is also the identifier, a
|
|
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.
|
|
1555
|
+
|
|
1556
|
+
**Where the data has a history, show the trajectory, not just the latest value.** A figure states
|
|
1557
|
+
where things stand; a line, a bar series or a `Sparkline` states where they are GOING, and the
|
|
1558
|
+
direction is usually the decision — a balance that is falling, a return rate that is climbing, a
|
|
1559
|
+
week that broke the pattern. This is the most common honest source of visual interest in work
|
|
1560
|
+
software, because the shape *is* information.
|
|
1561
|
+
|
|
1562
|
+
**A register whose entity has ordered stages should render the stage, not just name it.** A word in
|
|
1563
|
+
a cell makes the reader know the vocabulary and its order; a small coloured progress indicator makes
|
|
1564
|
+
position legible at a glance and lets rows be compared down the column — which is the question a
|
|
1565
|
+
register is asked. Colour it by what the stage MEANS (moving / stalled / done / failed), so the
|
|
1566
|
+
column reads as a state of play rather than a palette. Do not spend this on a two-state field: a
|
|
1567
|
+
progress rendering of "open / closed" is a badge wearing a costume.
|
|
1598
1568
|
|
|
1599
1569
|
## Where the accent goes — "where you are", and nothing else
|
|
1600
1570
|
|
|
@@ -1608,8 +1578,8 @@ case. `primary` is already the ink the kit spends on "this is on": a checkbox's
|
|
|
1608
1578
|
track, a picked option's tick, the one primary button. An active tab is the same claim at page
|
|
1609
1579
|
scale, so it belongs to the same ink, and the rail then sits at the same weight as the CTA it
|
|
1610
1580
|
shares a screen with. Painted in `accent`, an unthemed screen gets exactly one blue thing on it —
|
|
1611
|
-
a single-member hue from a family used nowhere else, which is
|
|
1612
|
-
|
|
1581
|
+
a single-member hue from a family used nowhere else, which is a library default nobody chose (see
|
|
1582
|
+
§ Color discipline).
|
|
1613
1583
|
|
|
1614
1584
|
The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
|
|
1615
1585
|
filters), `primary` marks THIS IS ON (a control's state, the section you are in).
|
|
@@ -1620,51 +1590,41 @@ keeping the ground every other chip and card has. The two altitudes deliberately
|
|
|
1620
1590
|
is the row's own language, so a control that borrows it says the same thing twice, and a control
|
|
1621
1591
|
has a border to say it with while a row does not.
|
|
1622
1592
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
the busiest surface in the product; making the token neutral to fix that turned a themed app's drop
|
|
1626
|
-
target grey and left a token named for the brand that resolved to zinc. Selections carry their own
|
|
1627
|
-
LITERALS now, so "is this selected" never depends on whether a tenant set an accent, and
|
|
1628
|
-
`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
|
|
1629
1595
|
lighting up under a dragged file — never on which record is open.
|
|
1630
1596
|
|
|
1631
|
-
**Selection and hover must not share a ground
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
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.
|
|
1638
1603
|
|
|
1639
1604
|
### Theming is an app-level ESCAPE HATCH, not a product surface
|
|
1640
1605
|
|
|
1641
1606
|
An app sets its own identity in its own code — `applyLoticsTheme({ … })` once at boot, before
|
|
1642
1607
|
`mount`. **Nothing in the platform writes a theme**,
|
|
1643
1608
|
there is no settings screen for it, and `apps.theme.color` is the LAUNCHER ICON's colour and
|
|
1644
|
-
nothing else.
|
|
1645
|
-
exists because a customer occasionally needs their own typeface, not because theming is a feature
|
|
1646
|
-
we sell.
|
|
1609
|
+
nothing else. That is the boundary, not a gap waiting to be filled.
|
|
1647
1610
|
|
|
1648
1611
|
**What makes it possible is small and load-bearing.** `colors.web.ts` reads five roles through a
|
|
1649
1612
|
CSS variable with the kit's own value as the inline fallback, and `font_family.web.ts` does the
|
|
1650
1613
|
same for the three weight rungs. That indirection is the ENTIRE mechanism — and it cannot be
|
|
1651
|
-
replaced by wrapping a component and passing styles down
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
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.
|
|
1657
1619
|
|
|
1658
1620
|
**Prefer `applyLoticsTheme` over declaring the variables by hand**, because two of the values are
|
|
1659
1621
|
DERIVED and getting either wrong fails quietly: `accent_wash` is the accent at 7%, and one
|
|
1660
1622
|
`bodyFont` has to reach all three weight rungs or the medium and semibold runs stay in the kit's
|
|
1661
1623
|
own face and the screen reads as a font that half-loaded.
|
|
1662
1624
|
|
|
1663
|
-
**It is a function, not a provider
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
with no lifecycle. Calling it before `mount` is also strictly better than a provider could be: the
|
|
1667
|
-
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.
|
|
1668
1628
|
|
|
1669
1629
|
## Color discipline — solid / tint / ramp + ONE accent
|
|
1670
1630
|
|
|
@@ -1701,9 +1661,8 @@ the sizes are.
|
|
|
1701
1661
|
`color="muted"` is the token that means "this supports something else"; a hand-written
|
|
1702
1662
|
`color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
|
|
1703
1663
|
already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
|
|
1704
|
-
and a
|
|
1705
|
-
|
|
1706
|
-
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.
|
|
1707
1666
|
|
|
1708
1667
|
**TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
|
|
1709
1668
|
`Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
|
|
@@ -1730,8 +1689,7 @@ cue. That is the ONLY thing it is for; keep the vocabulary scarce so a colored c
|
|
|
1730
1689
|
status. **A type / category / attribute / count is NOT a status — render it as inline `Text` (a
|
|
1731
1690
|
muted metadata line, free to wrap the row taller), NEVER a Badge.** A party's type, a document
|
|
1732
1691
|
kind, a city, "3 files" → text. If you're reaching for a Badge in a row or an option list "to
|
|
1733
|
-
show more info," STOP — that's text.
|
|
1734
|
-
through combobox option rows.)
|
|
1692
|
+
show more info," STOP — that's text.
|
|
1735
1693
|
|
|
1736
1694
|
When it IS a status, match weight to prominence — lightest → heaviest:
|
|
1737
1695
|
|
|
@@ -1751,9 +1709,9 @@ Prefer `dot` when the column is secondary or the row already carries a coloured
|
|
|
1751
1709
|
`tonal` when the status column is what the register is SCANNED by — the tints are the palette's
|
|
1752
1710
|
50-step against its 900-step ink, comfortably clear of the AA floor, and
|
|
1753
1711
|
a column of them is the difference between a register that reads as organised and one that reads
|
|
1754
|
-
as a grey spreadsheet. That
|
|
1755
|
-
|
|
1756
|
-
|
|
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.
|
|
1757
1715
|
|
|
1758
1716
|
**A `Badge` in a table cell needs `alignSelf`.** A cell stretches its child on the cross axis, and
|
|
1759
1717
|
a `dot` has no ground so nothing shows — swap the variant to `tonal` and the pill becomes a filled
|
|
@@ -1771,9 +1729,8 @@ not a form they fill down. Stacked one per row at the page's own rhythm, a handf
|
|
|
1771
1729
|
values can spend a quarter of the first screen while the viewport's right half sits empty. Pair
|
|
1772
1730
|
them into columns above the width where a label and its value still fit.
|
|
1773
1731
|
|
|
1774
|
-
The tell is a screen reported as **plain** whose treatment is fine
|
|
1775
|
-
|
|
1776
|
-
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.
|
|
1777
1734
|
|
|
1778
1735
|
**A record page also owes the reader the SIZE of its subject.** Where the data is quantities — how
|
|
1779
1736
|
many lines, how heavy, worth what — a page that holds them only inside sections makes a reader open
|
|
@@ -1802,11 +1759,9 @@ cluttered rather than as small. Recede with INK, at the same rung. `xs` is for g
|
|
|
1802
1759
|
timestamp, a count), not for a label that a person actually has to read.
|
|
1803
1760
|
|
|
1804
1761
|
**A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
|
|
1805
|
-
flex row, and whichever cannot shrink forces the other to.
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
values happen to fit, which is usually the width it was built at, and it appears the first time a
|
|
1809
|
-
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
|
|
1810
1765
|
whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
|
|
1811
1766
|
and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
|
|
1812
1767
|
recognisably a phone number.
|
|
@@ -1837,13 +1792,9 @@ steepens as the type grows — nothing in the kit tracks positive except `transf
|
|
|
1837
1792
|
which compensates for the caps itself.
|
|
1838
1793
|
|
|
1839
1794
|
A hand-set `letterSpacing` is a finding wherever it appears, and it fails in a specific way: it
|
|
1840
|
-
looks like a local decision and is actually a second copy of the curve
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
itself that was the whole of native's tracking, and `+0.3` left on a column header after its size
|
|
1844
|
-
and weight had been corrected. Each was defensible in its own file and wrong on the screen. If
|
|
1845
|
-
something cannot be wrapped in `Text` (a raw `TextInput`, a chart axis), take the rung from
|
|
1846
|
-
`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.
|
|
1847
1798
|
|
|
1848
1799
|
Weights: `regular` / `medium` /
|
|
1849
1800
|
`semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
|
|
@@ -1856,6 +1807,16 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
|
|
|
1856
1807
|
canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
|
|
1857
1808
|
hierarchy, not cramming.
|
|
1858
1809
|
|
|
1810
|
+
**Space states what belongs together, so the gap INSIDE a unit is always smaller than the gap
|
|
1811
|
+
BETWEEN units.** A line item's name, its code and its quantity are one thing and sit tight; the
|
|
1812
|
+
next line item starts after a clearly larger gap. Get the ratio backwards — or make both gaps
|
|
1813
|
+
equal — and the reader has to reconstruct the grouping from the content, because the layout is
|
|
1814
|
+
telling them the parts are peers of the wholes. The tell is a stack that reads as an undifferentiated
|
|
1815
|
+
list when you unfocus your eyes, or a supporting line drifting so far from the value it supports
|
|
1816
|
+
that it looks like the next unit's first line. This is the same law the gap-ratio probe in
|
|
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.
|
|
1819
|
+
|
|
1859
1820
|
**A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
|
|
1860
1821
|
Padding that happens to sum to the band height is a second owner of that number, and it drifts the
|
|
1861
1822
|
moment the type ramp moves — which the ramp is allowed to do. The signature is one control
|
|
@@ -1884,10 +1845,9 @@ React Native Web maps a subset of RN's accessibility props and **silently drops
|
|
|
1884
1845
|
<Pressable accessibilityRole="tab" aria-selected={isActive} />
|
|
1885
1846
|
```
|
|
1886
1847
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
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.
|
|
1891
1851
|
|
|
1892
1852
|
Write the raw `aria-*` attribute for any STATE (`aria-selected`, `aria-checked`,
|
|
1893
1853
|
`aria-expanded`, `aria-disabled`, `aria-current`). `accessibilityRole` and
|
|
@@ -1903,8 +1863,7 @@ no matter how long you `waitFor`. That covers everything built on it: the `Combo
|
|
|
1903
1863
|
this reason — there is no render test for any of them.
|
|
1904
1864
|
|
|
1905
1865
|
The trap is that the CONTROL works fine, so the failure looks like a wiring bug: the input takes
|
|
1906
|
-
its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere.
|
|
1907
|
-
debugging the component instead of the harness.
|
|
1866
|
+
its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere.
|
|
1908
1867
|
|
|
1909
1868
|
So split the assertion:
|
|
1910
1869
|
|
|
@@ -1918,6 +1877,42 @@ A component test can still assert everything BEFORE the overlay opens: the empty
|
|
|
1918
1877
|
placeholder, the disabled state, and — often the point — what the screen refuses to claim while
|
|
1919
1878
|
nothing is selected.
|
|
1920
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
|
+
|
|
1921
1916
|
## Microcopy — content, not mechanics
|
|
1922
1917
|
|
|
1923
1918
|
UI copy states WHAT something is — domain content or one real rule — never how the widget
|
|
@@ -1947,8 +1942,8 @@ that praises it is marketing.
|
|
|
1947
1942
|
- **NEVER join text with a middot — ` · ` is banned.** It is punctuation doing a word's job: it
|
|
1948
1943
|
asserts that two things are related while refusing to say how, leaving the reader to guess
|
|
1949
1944
|
between "and", "of", "for", "then", and "here is another fact". It is also the single loudest
|
|
1950
|
-
templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it
|
|
1951
|
-
|
|
1945
|
+
templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it. Use the
|
|
1946
|
+
mark that MEANS what you intend:
|
|
1952
1947
|
**`:`** for a run-in label naming its value (`Hàng hoá: <goods>`), **`( )`** for a
|
|
1953
1948
|
supplementary count or qualifier (`Đã đọc biểu thuế (2 bước)`), **`;`** between two related
|
|
1954
1949
|
clauses, **`,`** in a genuine list of like items, a real preposition when the relation is
|
|
@@ -1995,8 +1990,7 @@ one exists).
|
|
|
1995
1990
|
|
|
1996
1991
|
An empty region has three jobs (NN/g): confirm this is EMPTY — not loading, not broken — and
|
|
1997
1992
|
say what belongs here. The `EmptyState` props are that anatomy and nothing more (`message` =
|
|
1998
|
-
what's empty, `hint` = what to do about it).
|
|
1999
|
-
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).
|
|
2000
1994
|
|
|
2001
1995
|
**`EmptyState` carries NO verb.** The add is on the heading row (§ The add-placement law) and
|
|
2002
1996
|
duplicating it here would put two buttons for one act in view and make the add jump the moment
|
|
@@ -2016,10 +2010,8 @@ default holds a screenful because nothing else is in view: collapsing to two gre
|
|
|
2016
2010
|
read as a rendering bug rather than a state, which is the first of the three jobs. Inside a
|
|
2017
2011
|
record that premise is false. The band's heading above it, the sections below it and the add
|
|
2018
2012
|
verb beside it have already proved the surface rendered; the only open question is what this one
|
|
2019
|
-
band holds, and a screenful is a long way to say "nothing yet"
|
|
2020
|
-
|
|
2021
|
-
the section the reader opened the record FOR under the fold on a 768px screen — every probe
|
|
2022
|
-
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**.
|
|
2023
2015
|
|
|
2024
2016
|
So: `compact` on a band, the default on a region. It is a DENSITY axis, not a second component —
|
|
2025
2017
|
same three jobs, same two strings, same no-verb rule. Compact also sets its text to the LEFT,
|
|
@@ -2032,11 +2024,9 @@ at a fixed stroke on a 24 viewBox, so the artwork fills 20/24 of whatever box yo
|
|
|
2032
2024
|
`sm` text the CAP HEIGHT is 11px, so a mark has to be ~13px to sit ON the line instead of
|
|
2033
2025
|
straddling it — and at 13–16px a glyph with interior detail (`package`, `receipt`, `file-text`)
|
|
2034
2026
|
closes up into a smudge. At 20px it reads, and its 16.7px of artwork then stands half again as
|
|
2035
|
-
tall as the capitals beside it, rising above them and dropping past the baseline.
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
wants the block form, where it gets 28px and a line of its own. Here the anchoring is already
|
|
2039
|
-
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.
|
|
2040
2030
|
|
|
2041
2031
|
### Buttons & action labels — the outcome, verb-first
|
|
2042
2032
|
|