@lotics/ui 43.4.0 → 43.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/MIGRATION.md +93 -0
- package/docs/catalog.md +128 -26
- package/docs/composition.md +380 -9
- package/docs/data_entry.md +25 -0
- package/docs/reviewing.md +477 -0
- package/docs/templates.md +9 -6
- package/examples/tpl_attendance.tsx +0 -1
- package/examples/tpl_item_list.tsx +109 -54
- package/examples/tpl_record.tsx +89 -4
- package/package.json +7 -3
- package/src/avatar.tsx +29 -29
- package/src/avatar.web.tsx +32 -31
- package/src/avatar_props.ts +66 -0
- package/src/avatar_tone.ts +79 -0
- package/src/button.tsx +36 -4
- package/src/checkbox.tsx +4 -1
- package/src/choice_list.tsx +5 -3
- package/src/color_tokens.ts +15 -14
- package/src/composer.tsx +3 -3
- package/src/control_surface.ts +37 -9
- package/src/copy_button.tsx +8 -1
- package/src/counter.tsx +1 -1
- package/src/data_grid.tsx +6 -3
- package/src/date_calendar.tsx +4 -2
- package/src/date_range_filter_field.tsx +5 -1
- package/src/date_segments_field.tsx +2 -2
- package/src/file_drop_target.web.tsx +2 -2
- package/src/file_dropzone.tsx +12 -6
- package/src/file_rows.tsx +22 -2
- package/src/file_thumbnail.tsx +19 -3
- package/src/font_family.ts +1 -1
- package/src/font_family.web.ts +1 -1
- package/src/funnel.tsx +1 -1
- package/src/icon_button.tsx +5 -2
- package/src/index.css +22 -16
- package/src/inline_edit.tsx +9 -10
- package/src/inline_files.tsx +6 -0
- package/src/json_panel.tsx +1 -1
- package/src/kpi_card.tsx +1 -1
- package/src/locale.tsx +1 -1
- package/src/markdown.css +5 -1
- package/src/metric.tsx +12 -18
- package/src/number_input.tsx +2 -2
- package/src/option_picker.tsx +57 -0
- package/src/picker.tsx +2 -2
- package/src/pressable_row.tsx +15 -5
- package/src/progress_bar.tsx +1 -1
- package/src/radio_picker.tsx +2 -1
- package/src/search_input.tsx +12 -11
- package/src/sort_header.tsx +7 -3
- package/src/stacked_progress_bar.tsx +1 -1
- package/src/step_progress.tsx +5 -2
- package/src/switch.tsx +11 -6
- package/src/table.tsx +148 -16
- package/src/table_fit.ts +12 -0
- package/src/tabs.tsx +13 -1
- package/src/text.css +50 -18
- package/src/text.tsx +37 -40
- package/src/text_input_field.tsx +2 -2
- package/src/text_utils.ts +48 -6
- package/src/theme.ts +13 -0
- package/src/theme.web.ts +49 -0
- package/src/theme_vars.ts +113 -0
- package/src/type_ramp.ts +100 -0
- package/src/theme.tsx +0 -24
- package/src/theme.web.tsx +0 -79
- package/src/theme_context.ts +0 -107
package/docs/composition.md
CHANGED
|
@@ -33,6 +33,26 @@ them produces off-system UI.
|
|
|
33
33
|
| Register / work list / calendar | 1040 |
|
|
34
34
|
| Wide wallboard / report | 1100 |
|
|
35
35
|
|
|
36
|
+
**ONE LEFT EDGE — the whole page, and then the whole APP.** Headings, detail labels, prose, a
|
|
37
|
+
table's first column, a footer's totals, a dialog's title. Then check the screen this one OPENS: a
|
|
38
|
+
list and the record it opens are one reading column seen twice, and both must derive from ONE
|
|
39
|
+
constant rather than happening to match today.
|
|
40
|
+
|
|
41
|
+
The offender is nearly always a row that PADDED for its own hover wash instead of bleeding it
|
|
42
|
+
(`ROW_WASH_BLEED`), which pushes its content in by the bleed while everything above it stays put.
|
|
43
|
+
Fix the row, then DELETE the compensators that were added around it — once the row is right they
|
|
44
|
+
become double-corrections, and they are the reason the edge drifts back the next time someone
|
|
45
|
+
touches it. Two exceptions are legitimate and neither needs a fix: a self-contained overlay (a
|
|
46
|
+
menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies — a
|
|
47
|
+
checkbox, an ordinal, an identity mark — is explained by the thing occupying it.
|
|
48
|
+
|
|
49
|
+
**A row beat belongs to the PAGE, not to the row.** Collect every row primitive's height on one
|
|
50
|
+
screen before calling any of them right — the kit's own defaults do not agree with each other,
|
|
51
|
+
because each was picked for its own surface: `ListItem` is 64, a ruled `Table` row is 52, a
|
|
52
|
+
`Button`-height control is 40. Stack three of those in one column and it reads as three rhythms,
|
|
53
|
+
each defensible alone. The page picks one beat and the primitives take it (`minHeight` on the
|
|
54
|
+
row, `spacing` on the table).
|
|
55
|
+
|
|
36
56
|
## The heading ramp — one construct per altitude, no drift
|
|
37
57
|
|
|
38
58
|
**The ramp is FIXED — the markdown ladder, no size knobs.** One outline on every surface; never
|
|
@@ -45,9 +65,21 @@ restyle a heading level per-page.
|
|
|
45
65
|
| `###` | `lg` semibold | Subsection title — `SubsectionHeadingTitle`, always |
|
|
46
66
|
| body | `sm` | Everything else |
|
|
47
67
|
|
|
48
|
-
- **Page band** —
|
|
49
|
-
|
|
50
|
-
and
|
|
68
|
+
- **Page band** — the screen's TYPE ANCHOR, and only secondarily a label. A register's own type
|
|
69
|
+
runs 12–16px, so a page without a band tops out around a **1.33x** range between its largest and
|
|
70
|
+
smallest text, and that reads flat no matter how much colour is on it — scale is the one
|
|
71
|
+
hierarchy device colour cannot substitute for. Restoring a `Text size="xxl" weight="semibold"`
|
|
72
|
+
title takes the same screen to ~2.7x. It also gives the page somewhere to land: without it the
|
|
73
|
+
first thing on the screen is a search input, which is chrome.
|
|
74
|
+
Yes, the title usually repeats the nav. Accept the repetition — a heading that names the screen
|
|
75
|
+
costs one line and buys the whole type range, and the alternative (inventing a title the nav
|
|
76
|
+
does not say) is worse.
|
|
77
|
+
**A second line carries LIVE STATE, never a gloss.** "Track each record's status and print the
|
|
78
|
+
right document for its stage" explains the WIDGET, and a band has no use for it. But a
|
|
79
|
+
register's counts are not that line either: they are the result of the TOOLBAR, so they belong
|
|
80
|
+
under it — see § Summaries.
|
|
81
|
+
Right side: the screen's ONE primary action and/or a period filter — never a
|
|
82
|
+
summary `Badge` (those belong to the KPI strip).
|
|
51
83
|
- **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
|
|
52
84
|
`CardHeaderTitle` (`sm` semibold; pass `info` whenever the title alone doesn't define the
|
|
53
85
|
numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
|
|
@@ -222,6 +254,43 @@ by WHAT THE SURFACE IS; never a raw `Pressable`.**
|
|
|
222
254
|
| 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` |
|
|
223
255
|
| Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
|
|
224
256
|
|
|
257
|
+
**ONE signal per species, and it has to be strong enough to carry alone.** `HOVER_BORDER` is
|
|
258
|
+
zinc-500, not 400, because at 400 the shift was zinc-200 → zinc-400 on a 1px line — measurably a
|
|
259
|
+
change and visually almost nothing, so the inline editors had grown a background TINT on top to
|
|
260
|
+
compensate. That left two hover languages in one kit, and a reader crossing a record surface met
|
|
261
|
+
both. Strengthen the shared signal rather than letting one control add a second; a control wearing
|
|
262
|
+
two is the tell that the first one is too weak for everybody.
|
|
263
|
+
|
|
264
|
+
**A themeable control derives EVERY state from its token — rest, hover, press, disabled.** The
|
|
265
|
+
failure is a control that resolves `colors.primary` correctly at rest and then paints a fixed
|
|
266
|
+
neighbouring zinc the moment it is touched, so a branded button turns grey under the pointer. Watch
|
|
267
|
+
for an OPAQUE overlay especially: a `backgroundImage` gradient drawn over a themed
|
|
268
|
+
`backgroundColor` hides it entirely while every token in the source reads correct. On web a token
|
|
269
|
+
is a `var()`, so "the same colour, lighter" cannot be computed at the call site — use a translucent
|
|
270
|
+
white/black overlay, which lifts any hue and cannot go stale against a palette it does not know.
|
|
271
|
+
|
|
272
|
+
**A control changes PAINT across its states and never GEOMETRY** — and every property it paints
|
|
273
|
+
has exactly ONE owner. Text that shifts on focus, a border that thickens, a ring drawn twice, a
|
|
274
|
+
tint applied on one path only: each is two pieces of code owning one property and happening to
|
|
275
|
+
agree at rest. It survives review because a resting screen is what gets looked at, and the second
|
|
276
|
+
owner only speaks when the control is touched. The fix is to give the property one owner — a
|
|
277
|
+
shared surface helper, a single frame kept across the view→edit swap — never to nudge the second
|
|
278
|
+
owner into agreeing with the first, which leaves both in place for the next person to re-break.
|
|
279
|
+
|
|
280
|
+
**A control riding a hoverable surface must be DARKER than that surface** — and you have to hover
|
|
281
|
+
BOTH to see whether it is, because reaching the control means crossing its host. Equal washes
|
|
282
|
+
vanish at exactly the moment they are pointed at; a control LIGHTER than its host reads as a hole
|
|
283
|
+
punched in the row rather than as a target sitting on it. Neither failure is visible at rest, and
|
|
284
|
+
neither is visible hovering the control alone, which is why a control that measures perfectly can
|
|
285
|
+
still disappear in use. Take `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` (`control_surface`), which are a step past the
|
|
286
|
+
row's own wash for exactly this reason; a hand-picked grey chosen against the resting row is the
|
|
287
|
+
finding however plausible the value looks.
|
|
288
|
+
|
|
289
|
+
**A SELECTED control needs a GROUND, not a heavier outline.** Among white pills a 1px border change
|
|
290
|
+
is noise — it is the same signal hover already spends, one step stronger, so selection and hover
|
|
291
|
+
compete instead of reading as different things. Selection changes what the box IS (a fill, a wash);
|
|
292
|
+
hover changes how intensely it says it.
|
|
293
|
+
|
|
225
294
|
For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
|
|
226
295
|
open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. The bug this
|
|
227
296
|
grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
|
|
@@ -264,8 +333,17 @@ objects — hairlines between them blur into one run-on list.
|
|
|
264
333
|
| Altitude | Component | Where | Shape |
|
|
265
334
|
|---|---|---|---|
|
|
266
335
|
| Dashboard | `<KPIStrip items={[{label, value, trend?, caption?, tone?, compact?, info?}…]}>` | TOP of a dashboard | a boxed stat band — a `Card` of `KPICard` columns |
|
|
267
|
-
| Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?, color?}…]}>` | BELOW the toolbar | a light inline line — a small `Metric` + muted label per item |
|
|
268
|
-
| Register / list, with a SHAPE | `<Summary buckets={…}><Summary.Header>…</Summary.Header><Summary.Distribution/><Summary.Facts/></Summary>` | BELOW the toolbar | headline figure + distribution bar + the buckets named, all derived from one bucket list |
|
|
336
|
+
| Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?, color?}…]}>` | BELOW the toolbar, above the rows | a light inline line — a small `Metric` + muted label per item |
|
|
337
|
+
| Register / list, with a SHAPE | `<Summary buckets={…}><Summary.Header>…</Summary.Header><Summary.Distribution/><Summary.Facts/></Summary>` | BELOW the toolbar, above the rows | headline figure + distribution bar + the buckets named, all derived from one bucket list |
|
|
338
|
+
|
|
339
|
+
**A summary goes BELOW the controls that produce it.** Its figures are the result of the toolbar —
|
|
340
|
+
they move every time the reader touches a filter — so the layout has to state that order: set the
|
|
341
|
+
query, then read what it returned. Above the toolbar the screen answers before it has been asked,
|
|
342
|
+
and the numbers change behind the reader's eye while they are still choosing.
|
|
343
|
+
|
|
344
|
+
It is not the page band's subtitle either, for the same reason. A subtitle describes the SCREEN,
|
|
345
|
+
which does not change; these describe the QUERY, which the toolbar just rewrote. The band carries
|
|
346
|
+
the title, and the summary sits under the controls, immediately above the rows it counts.
|
|
269
347
|
|
|
270
348
|
`SummaryLine` is `Summary`'s `Facts` row on its own — reach for the compound when the set has a
|
|
271
349
|
distribution worth drawing, and for the bare line when it is only a few aggregates. A
|
|
@@ -445,8 +523,9 @@ Two corollaries, both counter-intuitive enough to state:
|
|
|
445
523
|
- **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
|
|
446
524
|
cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
|
|
447
525
|
a 68px one. Only bounding the cell that varies removes it.
|
|
448
|
-
- **The fix is never a cross-axis knob.**
|
|
449
|
-
|
|
526
|
+
- **The fix is never a cross-axis knob.** A column's `align` is horizontal (`"left"`/`"right"`,
|
|
527
|
+
for a money column); there is deliberately no VERTICAL one, because topping the cells leaves
|
|
528
|
+
the chrome centred and the row reads as scatter instead.
|
|
450
529
|
|
|
451
530
|
Bound a cell by giving every row the same content, not by hoping the data is uniform. A line that
|
|
452
531
|
appears only when its field is set is the defect; RESERVING the line is the fix — **but only when
|
|
@@ -987,6 +1066,249 @@ a pointer. `PressableHighlight` and `FocusRingPressable` set the arrow for every
|
|
|
987
1066
|
on them (`CURSOR_DEFAULT`); a component on the bare `Pressable` sets it itself, and the few
|
|
988
1067
|
that act set `CURSOR_ACTION` back over the top.
|
|
989
1068
|
|
|
1069
|
+
## The register's rhythm — no rule between rows, one under the band
|
|
1070
|
+
|
|
1071
|
+
A register's rows are separated by their own height and a small gap. The only mark a row makes is
|
|
1072
|
+
the rounded wash it paints when you point at it. There is no hairline between rows and no way to
|
|
1073
|
+
ask for one.
|
|
1074
|
+
|
|
1075
|
+
| | |
|
|
1076
|
+
|---|---|
|
|
1077
|
+
| Between rows | nothing — 4px and their own height |
|
|
1078
|
+
| Under the column band | a hairline (`colors.border`) |
|
|
1079
|
+
| Row height | 72, overridable per row with `TableRow`'s `minHeight` |
|
|
1080
|
+
|
|
1081
|
+
**WHAT a line separates is the whole test — not how big the break feels.** A rule between two rows
|
|
1082
|
+
divides LIKE FROM LIKE, and the space between them already says that. The band's rule divides
|
|
1083
|
+
CHROME from CONTENT — a row of labels from the records they name — and no amount of whitespace
|
|
1084
|
+
states that, because whitespace is what already separates the rows from each other. Removing it was
|
|
1085
|
+
tried on a real register and the column band floated: with nothing beneath them the labels read as
|
|
1086
|
+
a first row rather than as the header of the rest.
|
|
1087
|
+
|
|
1088
|
+
Drawing both makes them compete, and a grid of equal lines is most of what "it looks like a
|
|
1089
|
+
spreadsheet" means.
|
|
1090
|
+
|
|
1091
|
+
**The hover wash does the work the rule did**, and it already has the shape: `PressableRow` bleeds
|
|
1092
|
+
its ground past the content and rounds it, so a register reads as a stack of cards with no card
|
|
1093
|
+
component involved. That is why dropping the rules does not make rows ambiguous — the separator
|
|
1094
|
+
moved from a permanent line to a pointer-following one. The row has to be tall enough for that wash
|
|
1095
|
+
to read as a surface rather than a stripe, which is what the 72 buys.
|
|
1096
|
+
|
|
1097
|
+
**Density is a number, never a mode.** A surface that genuinely needs more rows on screen sets
|
|
1098
|
+
`minHeight` on the row. What no caller can ask for is the rules back — and that is deliberate,
|
|
1099
|
+
because a table of VALUES rather than of objects is a different component: `Matrix` for a numeric
|
|
1100
|
+
grid (a statement, a pivot, a rollup), `DataGrid` for a compact one. The distinction the two
|
|
1101
|
+
rhythms used to express already lived at the component boundary; offering it again as a prop was a
|
|
1102
|
+
third way to say the same thing, and the way that let a register contradict this rule.
|
|
1103
|
+
|
|
1104
|
+
## A register that TRIAGES — `TableGroup`
|
|
1105
|
+
|
|
1106
|
+
A flat register sorted by a column hands the reader every row at equal weight and leaves the
|
|
1107
|
+
deciding to them: "which of these needs me today" is answered by reading all of them. `TableGroup`
|
|
1108
|
+
moves that decision into the screen — a titled band of rows with a count.
|
|
1109
|
+
|
|
1110
|
+
**Group by what implies a different ACTION, never by a category the reader can already see.**
|
|
1111
|
+
Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
|
|
1112
|
+
the grouping tells the reader something no column does. Grouping by a value that is already a
|
|
1113
|
+
column is a sort wearing chrome: it spends a band per value to repeat the cell beside it.
|
|
1114
|
+
|
|
1115
|
+
- **Order the bands by how much they need the reader NOW**, not alphabetically and not by size. A
|
|
1116
|
+
register is read top-down, so the first band is the answer to "what do I do today".
|
|
1117
|
+
A stated next step outranks silence — it is a commitment already made, and it names the work.
|
|
1118
|
+
- **An empty band renders nothing**, never an empty heading: a title over no rows states a pile
|
|
1119
|
+
that is not there.
|
|
1120
|
+
- **At most one band carries a dot.** A valence mark on every band is decoration; on the one band
|
|
1121
|
+
whose urgency comes from a CLOCK rather than from something someone wrote down, it is a signal.
|
|
1122
|
+
- **Air separates bands, a hairline separates rows.** The band boundary is the largest break
|
|
1123
|
+
inside the register and the row rules are the smallest — drawing both as lines makes them
|
|
1124
|
+
compete and the grouping stops reading. No rule under a band heading either; that orphans the
|
|
1125
|
+
title from the rows it opens.
|
|
1126
|
+
- **Ordinals restart inside a band** where they are shown: the reader's question there is "which
|
|
1127
|
+
of these", not "which of all".
|
|
1128
|
+
- **Watch the ratio of headings to rows.** Four bands over ten rows is a lot of chrome for a
|
|
1129
|
+
little content, and the shape only pays off as the register grows. A register that will never
|
|
1130
|
+
hold more than a screenful is usually better flat.
|
|
1131
|
+
|
|
1132
|
+
Banding also changes what the COLUMNS have to carry, and the check is worth running afterwards: a
|
|
1133
|
+
column that exists to signal presence ("is there a next step?") is now stated by the band, so it
|
|
1134
|
+
is left carrying only its text — and it will be empty in every band but one. That is not
|
|
1135
|
+
necessarily wrong (which pile against what specifically), but it is a new duplication to look at.
|
|
1136
|
+
|
|
1137
|
+
## The register's own craft — what "it looks like a spreadsheet" actually is
|
|
1138
|
+
|
|
1139
|
+
Three defects produce that report, none of them about colour, and all three measured on a shipped
|
|
1140
|
+
screen that had already passed a design audit. (The fourth was drawing a line between every row;
|
|
1141
|
+
that one is settled above — a register has one line, and it is the band.)
|
|
1142
|
+
|
|
1143
|
+
**A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
|
|
1144
|
+
it heads in size and separating from them by weight and ink. It is not an eyebrow: an eyebrow is
|
|
1145
|
+
`xs`, calibrated to sit quietly beside a value it captions, and a column header names a whole
|
|
1146
|
+
column of them. Dropped to `xs` and lightened past `muted` it stops being legible and starts
|
|
1147
|
+
reading as a caption that lost its value — small type is not the same as quiet type. The active
|
|
1148
|
+
SORTED column steps up in ink, never in weight; semibold belongs to the heading ladder.
|
|
1149
|
+
|
|
1150
|
+
**Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
|
|
1151
|
+
the #1 templated tell; `Table` and `SortHeader` were uppercasing every column label in every app,
|
|
1152
|
+
which is the loudest possible version of the thing the rule exists to stop. Removing the case
|
|
1153
|
+
without replacing the separation is what left the header looking adrift — the ink step above is
|
|
1154
|
+
the replacement, and the two changes only work together.
|
|
1155
|
+
|
|
1156
|
+
**A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
|
|
1157
|
+
`##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
|
|
1158
|
+
at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
|
|
1159
|
+
separate file, a heavier stroke than the number suggests). The subject leads by SCALE instead: one
|
|
1160
|
+
size step above the values beside it. Moving size and weight together is one step too many.
|
|
1161
|
+
|
|
1162
|
+
**A supporting line is ONE RUNG BELOW the line it supports — never a fixed size.** A `md` subject
|
|
1163
|
+
over an `xs` caption is a 16→12 drop, while the same shape in the next column drops 14→12; the
|
|
1164
|
+
wider pair then reads as though its second line shrank rather than stepped. Pick the rung
|
|
1165
|
+
relative to the primary and the pair scales together. The probe: collect every primary/supporting
|
|
1166
|
+
pair on the screen and divide. A spread in the RATIOS is the finding, not a spread in the sizes.
|
|
1167
|
+
|
|
1168
|
+
**A CONTROL riding a text line must not resize that line.** A control is taller than the text it
|
|
1169
|
+
sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
|
|
1170
|
+
the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
|
|
1171
|
+
starts lower, and the pair reads with a wider gap than the identical pair one column over that
|
|
1172
|
+
happens to carry no control. Measured on a register: 5px under the name against 2px under the
|
|
1173
|
+
figure beside it, from one `gap: 2` shared by both.
|
|
1174
|
+
|
|
1175
|
+
It is invisible in review because every number is defensible — the gap is right, the rungs are
|
|
1176
|
+
right, the control is the documented size — and the defect is the interaction between them. It is
|
|
1177
|
+
also the harder half of *same shape, same treatment*: the reason for the difference is real, and a
|
|
1178
|
+
reader cannot see it.
|
|
1179
|
+
|
|
1180
|
+
Pin the row to the TEXT's line box (`height` = the rung's leading) and let the control overflow it.
|
|
1181
|
+
The hit area is unchanged — it paints outside the box rather than growing it — and the text sits
|
|
1182
|
+
where it would if the control were not there. The same applies to any verb riding a value: a
|
|
1183
|
+
`CopyButton`, an `InlineButton`, a count chip.
|
|
1184
|
+
|
|
1185
|
+
## Identity marks — a mark that is the same on every row carries nothing
|
|
1186
|
+
|
|
1187
|
+
`Avatar` derives its ground from the NAME (`avatar_tone`), and takes `shape="circle" | "square"`
|
|
1188
|
+
— a person or an organization.
|
|
1189
|
+
|
|
1190
|
+
**The shape varies only where the surface MIXES entity types** and the reader acts on which is
|
|
1191
|
+
which: a search result holding both people and companies, a "related records" list, a picker over
|
|
1192
|
+
two directories. On a surface whose rows are all one kind of thing — a register of records, a
|
|
1193
|
+
roster, a list of accounts — pick ONE shape and hold it. The distinction is real, but a reader
|
|
1194
|
+
does not act on it there, and a column of mixed circles and squares reads as an inconsistency
|
|
1195
|
+
rather than as information. That is the general form: a structural device asserts a difference,
|
|
1196
|
+
and asserting one the reader cannot use costs more than it gives.
|
|
1197
|
+
|
|
1198
|
+
Both exist for the same reason: the mark's entire job is making a row identifiable at a glance,
|
|
1199
|
+
so it is the one element in the row that must not look the same on every row. A register of
|
|
1200
|
+
twenty people in one accent colour is twenty identical circles, with the largest and most
|
|
1201
|
+
colourful thing in the row carrying no information; and a register of companies drawn with a
|
|
1202
|
+
person's disc asserts they are the same kind of entity, which the column header alone has to
|
|
1203
|
+
correct.
|
|
1204
|
+
|
|
1205
|
+
- **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
|
|
1206
|
+
the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
|
|
1207
|
+
on every screen and in every session — the value is never stored.
|
|
1208
|
+
- **A palette of identity hues is ordered by CONTRAST, not by spectrum.** Listed by hue, six
|
|
1209
|
+
consecutive entries are six shades of one purple, and any two names hashing to adjacent buckets
|
|
1210
|
+
look identical. Interleave. This is invisible to every count-based test — a hash returning seven
|
|
1211
|
+
distinct buckets still renders six neighbouring purples — so it is checked by eye, on real
|
|
1212
|
+
names from the language the app actually runs in.
|
|
1213
|
+
- **A person is never `red` or `amber`.** Those mean danger and waiting everywhere else, and a
|
|
1214
|
+
palette that can paint someone red makes every red on the screen ambiguous.
|
|
1215
|
+
- **Every register row should carry a LEADING MARK, best effort from the data.** An avatar, a
|
|
1216
|
+
logo, a thumbnail, a file badge, a coloured initial — something the eye can aim at without
|
|
1217
|
+
reading. A register whose rows open with text starts flat, and the symptom is reported as
|
|
1218
|
+
anything but the cause ("that avatar looks too small", "it looks plain"), because a missing
|
|
1219
|
+
element is invisible by definition. A person gets initials; an account gets the squared mark; a
|
|
1220
|
+
record with no likeness of its own borrows the identity a reader actually scans for — a deal
|
|
1221
|
+
takes its account's mark, since "whose deal is this" is the question being asked of the list.
|
|
1222
|
+
Only rows with genuinely no identity are exempt: a list of amounts, a log of events.
|
|
1223
|
+
- **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
|
|
1224
|
+
where it is one field among columns (see `avatar_size`).
|
|
1225
|
+
|
|
1226
|
+
The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
|
|
1227
|
+
|
|
1228
|
+
## Character comes from the DATA — the answer when a screen reads "bland"
|
|
1229
|
+
|
|
1230
|
+
Every rule above removes a defect. None of them ADDS anything, so a screen can pass all of them
|
|
1231
|
+
and still be reported as plain, boring, or unfinished — which is a different complaint with a
|
|
1232
|
+
different fix. Removing defects converges on *inoffensive*; these three questions converge on
|
|
1233
|
+
*distinctive*.
|
|
1234
|
+
|
|
1235
|
+
- **What is inherently visual in THIS data?** Work software has no photography, so the data itself
|
|
1236
|
+
is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
|
|
1237
|
+
bars, tabular figures aligning down a column. Every one of these is information first and
|
|
1238
|
+
decoration never — which is why they are the only kind of visual interest that survives contact
|
|
1239
|
+
with a real dataset. Reaching instead for a gradient, an illustration or a hero band adds weight
|
|
1240
|
+
without adding a single fact.
|
|
1241
|
+
- **What does the screen DECIDE?** A register listing every row at equal weight has no point of
|
|
1242
|
+
view: it hands the reader the same undifferentiated wall the query returned. A screen with a
|
|
1243
|
+
point of view puts what needs attention first (`TableGroup`), spends its one accent on where the
|
|
1244
|
+
reader is, and lets everything else recede. This is usually the real answer when treatment fixes
|
|
1245
|
+
keep not landing.
|
|
1246
|
+
- **What makes this belong to THIS product?** Default typeface, default greys, default accent,
|
|
1247
|
+
default control shapes is a *well-built generic screen*, and no further polish changes that. The
|
|
1248
|
+
fix at that point is a theme (`applyLoticsTheme`), not another treatment pass — and knowing
|
|
1249
|
+
which of the two you are looking at saves a great deal of pointless nudging.
|
|
1250
|
+
|
|
1251
|
+
Type SCALE is the fourth, and it belongs with the others: range = largest ÷ smallest across the
|
|
1252
|
+
screen, and under about 2× reads flat *at any amount of colour*. Scale is the one hierarchy device
|
|
1253
|
+
colour cannot substitute for. A register with no page band tops out near 1.33×.
|
|
1254
|
+
|
|
1255
|
+
## Where the accent goes — "where you are", and nothing else
|
|
1256
|
+
|
|
1257
|
+
`colors.accent` paints the state that answers **where the reader is** on a SURFACE: the selected
|
|
1258
|
+
row's ground (`accent_wash`), an active filter. It never paints DATA — a status, a series and a
|
|
1259
|
+
valence all come from a palette family, so an accent can never overwrite a meaning — and it never
|
|
1260
|
+
paints the focus ring.
|
|
1261
|
+
|
|
1262
|
+
**A page-scale position marker takes `primary`, not `accent`** — an active tab's underline is the
|
|
1263
|
+
case. `primary` is already the ink the kit spends on "this is on": a checkbox's fill, a switch's
|
|
1264
|
+
track, a picked option's tick, the one primary button. An active tab is the same claim at page
|
|
1265
|
+
scale, so it belongs to the same ink, and the rail then sits at the same weight as the CTA it
|
|
1266
|
+
shares a screen with. Painted in `accent`, an unthemed screen gets exactly one blue thing on it —
|
|
1267
|
+
a single-member hue from a family used nowhere else, which is the definition of a library default
|
|
1268
|
+
nobody chose (see § Color discipline), three pixels from a near-black button.
|
|
1269
|
+
|
|
1270
|
+
The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
|
|
1271
|
+
filters), `primary` marks THIS IS ON (a control's state, the section you are in).
|
|
1272
|
+
|
|
1273
|
+
`accent_wash` is derived from `accent`, not chosen beside it. The two used to default to blue-600
|
|
1274
|
+
and zinc-100 — one token pair disagreeing with itself, so every app that themed nothing got a
|
|
1275
|
+
colourless "you are here".
|
|
1276
|
+
|
|
1277
|
+
**Selection and hover must not share a wash.** They did, and the register's one piece of
|
|
1278
|
+
persistent state was erased by a transient one: the row whose record is open looked exactly like
|
|
1279
|
+
the row under the pointer. Hover is neutral and lighter (it answers "you can press this"),
|
|
1280
|
+
selection carries the accent.
|
|
1281
|
+
|
|
1282
|
+
### Theming is an app-level ESCAPE HATCH, not a product surface
|
|
1283
|
+
|
|
1284
|
+
An app sets its own identity in its own code — `applyLoticsTheme({ … })` once at boot, before
|
|
1285
|
+
`mount`. **Nothing in the platform writes a theme**,
|
|
1286
|
+
there is no settings screen for it, and `apps.theme.color` is the LAUNCHER ICON's colour and
|
|
1287
|
+
nothing else. Read that as the boundary rather than as a gap waiting to be filled: the mechanism
|
|
1288
|
+
exists because a customer occasionally needs their own typeface, not because theming is a feature
|
|
1289
|
+
we sell.
|
|
1290
|
+
|
|
1291
|
+
**What makes it possible is small and load-bearing.** `colors.web.ts` reads five roles through a
|
|
1292
|
+
CSS variable with the kit's own value as the inline fallback, and `font_family.web.ts` does the
|
|
1293
|
+
same for the three weight rungs. That indirection is the ENTIRE mechanism — and it cannot be
|
|
1294
|
+
replaced by wrapping a component and passing styles down, which is the intuition to check before
|
|
1295
|
+
reaching for it. React Native Web writes `fontFamily` onto every `Text` element itself, so a
|
|
1296
|
+
wrapper's font has nothing to inherit into; and `background-color` and `border-color` do not
|
|
1297
|
+
inherit in CSS at all, so no ancestor can reach a `Button`'s fill or a `Table`'s hairlines. The
|
|
1298
|
+
only alternative to the variables is passing `style` at every call site, which is a fork rather
|
|
1299
|
+
than a theme.
|
|
1300
|
+
|
|
1301
|
+
**Prefer `applyLoticsTheme` over declaring the variables by hand**, because two of the values are
|
|
1302
|
+
DERIVED and getting either wrong fails quietly: `accent_wash` is the accent at 7%, and one
|
|
1303
|
+
`bodyFont` has to reach all three weight rungs or the medium and semibold runs stay in the kit's
|
|
1304
|
+
own face and the screen reads as a font that half-loaded.
|
|
1305
|
+
|
|
1306
|
+
**It is a function, not a provider**, and the shape follows from what theming IS: an app declares
|
|
1307
|
+
one identity at boot and never changes it. A React component bought a memo, a wrapper element, a
|
|
1308
|
+
layout effect, a restore-on-unmount and a context nobody read — all of it lifecycle for a value
|
|
1309
|
+
with no lifecycle. Calling it before `mount` is also strictly better than a provider could be: the
|
|
1310
|
+
variables are in place for the FIRST paint, where a provider's effect ran after one.
|
|
1311
|
+
|
|
990
1312
|
## Color discipline — solid / tint / ramp + ONE accent
|
|
991
1313
|
|
|
992
1314
|
Every status / data-viz / accent color comes from a NAMED helper (`@lotics/ui/colors`), never a
|
|
@@ -1003,6 +1325,13 @@ accent per screen purpose** (blue = pipeline, emerald = money, red = danger, amb
|
|
|
1003
1325
|
Direct `colors.*` access is reserved for NEUTRALS (`zinc`, `border`, `white`, the `*[50]`
|
|
1004
1326
|
selection washes).
|
|
1005
1327
|
|
|
1328
|
+
**Count the chromatic grounds on a finished screen — the tally is the check.** One or two hues used
|
|
1329
|
+
many times reads calm and deliberate; ZERO reads like a spreadsheet; ten used once each reads
|
|
1330
|
+
cheap. A hue with a SINGLE member, from a family used nowhere else on the screen, is a library
|
|
1331
|
+
default nobody chose — the giveaway is that the call site mentions no colour at all, so the shade
|
|
1332
|
+
arrived from a component's fallback rather than from a decision. Either give it siblings or take it
|
|
1333
|
+
back to a neutral.
|
|
1334
|
+
|
|
1006
1335
|
**TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
|
|
1007
1336
|
`Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
|
|
1008
1337
|
(emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
|
|
@@ -1069,6 +1398,22 @@ them on a shared baseline — the symptom is usually reported as "the alignment
|
|
|
1069
1398
|
The check is cheap and worth running whenever a component takes a size: render the small rung and
|
|
1070
1399
|
measure the TEXT inside it, not the box. If the text did not move, the prop is only half wired.
|
|
1071
1400
|
|
|
1401
|
+
**SMALL is not QUIET.** When a label reads wrong, check whether someone made it smaller *and*
|
|
1402
|
+
fainter to make it recede — that trades legibility for quiet, and it usually causes the very
|
|
1403
|
+
complaint it was meant to fix, because a reader who cannot read a label reports the block as
|
|
1404
|
+
cluttered rather than as small. Recede with INK, at the same rung. `xs` is for genuine meta (a
|
|
1405
|
+
timestamp, a count), not for a label that a person actually has to read.
|
|
1406
|
+
|
|
1407
|
+
**A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
|
|
1408
|
+
flex row, and whichever cannot shrink forces the other to. A value floored at its longest
|
|
1409
|
+
unbreakable word leaves the label absorbing every remaining pixel — collapsing to a
|
|
1410
|
+
one-character-wide column many times taller than the row it names. It is invisible wherever the
|
|
1411
|
+
values happen to fit, which is usually the width it was built at, and it appears the first time a
|
|
1412
|
+
real record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
|
|
1413
|
+
whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
|
|
1414
|
+
and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
|
|
1415
|
+
recognisably a phone number.
|
|
1416
|
+
|
|
1072
1417
|
|
|
1073
1418
|
|
|
1074
1419
|
Only the `Text` primitive (`size` / `weight` / `color` / `transform` / `tabular` / `level` for
|
|
@@ -1076,7 +1421,7 @@ semantic heading rank). Uppercase tracking is built into `transform="uppercase"`
|
|
|
1076
1421
|
`letterSpacing`.
|
|
1077
1422
|
|
|
1078
1423
|
On web, the heading sizes are **responsive** at the 768px viewport breakpoint (the kit's screen-size
|
|
1079
|
-
boundary); native uses the base column:
|
|
1424
|
+
boundary); native uses the base column, because a phone is a phone:
|
|
1080
1425
|
|
|
1081
1426
|
| `size` | base / native | web ≥ 768px | Role |
|
|
1082
1427
|
|---|---|---|---|
|
|
@@ -1088,7 +1433,22 @@ boundary); native uses the base column:
|
|
|
1088
1433
|
| `xxl` | 28 | 32 | `#` page/record title |
|
|
1089
1434
|
| `xxxl` | 32 | 48 | hero numbers |
|
|
1090
1435
|
|
|
1091
|
-
|
|
1436
|
+
**Leading and tracking are baked per rung — never set either by hand.** Both curves live in
|
|
1437
|
+
`type_ramp.ts`, and the rungs carrying running text sit at PROSE leading (14/24) so a typed note
|
|
1438
|
+
and the same note rendered as markdown read at one rhythm. Tracking is negative at every rung and
|
|
1439
|
+
steepens as the type grows — nothing in the kit tracks positive except `transform="uppercase"`,
|
|
1440
|
+
which compensates for the caps itself.
|
|
1441
|
+
|
|
1442
|
+
A hand-set `letterSpacing` is a finding wherever it appears, and it fails in a specific way: it
|
|
1443
|
+
looks like a local decision and is actually a second copy of the curve. Three of them shipped
|
|
1444
|
+
before this was written — a private size→tracking table on `Metric` that put the product's
|
|
1445
|
+
summary figures at ZERO while the sentence beside them tracked -0.48px, a flat value on `Text`
|
|
1446
|
+
itself that was the whole of native's tracking, and `+0.3` left on a column header after its size
|
|
1447
|
+
and weight had been corrected. Each was defensible in its own file and wrong on the screen. If
|
|
1448
|
+
something cannot be wrapped in `Text` (a raw `TextInput`, a chart axis), take the rung from
|
|
1449
|
+
`type_ramp` rather than picking a number.
|
|
1450
|
+
|
|
1451
|
+
Weights: `regular` / `medium` /
|
|
1092
1452
|
`semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
|
|
1093
1453
|
(`danger`/`warning`/`success`), and the explicit `zinc-900/700/500/400` steps (`zinc-400` is the
|
|
1094
1454
|
disabled ink).
|
|
@@ -1193,6 +1553,17 @@ that praises it is marketing.
|
|
|
1193
1553
|
a middot is usually a substitute for. Facts of two different KINDS want two lines, not one
|
|
1194
1554
|
line with a glyph between them. (Verbatim quoted DATA keeps its own delimiters — this governs
|
|
1195
1555
|
copy we write, not text we reproduce.)
|
|
1556
|
+
- **STRUCTURE MUST ENCODE SOMETHING TRUE.** Every structural device is an assertion, and a false
|
|
1557
|
+
one is decoration that costs more than it gives. A rule asserts *these are separate*; an eyebrow
|
|
1558
|
+
asserts *this labels what follows*; a number asserts *there is an order the reader needs*; a
|
|
1559
|
+
badge asserts *this is a state*; a colour asserts *this differs in kind from its neighbour*.
|
|
1560
|
+
Numbered markers on a set nobody traverses in order, a divider between two things that belong
|
|
1561
|
+
together, a coloured pill on a city — each reads as information and carries none, and the cost
|
|
1562
|
+
is paid by the devices that ARE telling the truth, which get discounted along with it.
|
|
1563
|
+
- **One job per element.** A label labels, an example demonstrates, a caption qualifies. Nothing
|
|
1564
|
+
quietly does double duty — and a SHORTENED element still has to justify existing, because
|
|
1565
|
+
trimming makes it pass every length check while leaving the question of whether it earns its
|
|
1566
|
+
place unanswered.
|
|
1196
1567
|
- **Plain language over jargon; effect over mechanism** ("Record visibility", not "Row-Level
|
|
1197
1568
|
Privacy") — if a term needs explaining, replace it. **One concept = one word everywhere** —
|
|
1198
1569
|
pick the term once and reuse it on every surface.
|
package/docs/data_entry.md
CHANGED
|
@@ -75,6 +75,31 @@ message on a `touched` flag set `onBlur`, keep the commit gated on the real vali
|
|
|
75
75
|
showing error clear live as the value becomes valid — the shape [`useForm`](../src/use_form.ts)
|
|
76
76
|
already has: errors arrive from a validate pass, and editing a field clears that field's error.
|
|
77
77
|
|
|
78
|
+
## A picker over a select FIELD keeps the field's colours — `optionPicker`
|
|
79
|
+
|
|
80
|
+
<InlineSelect {...optionPicker(fields.status?.options ?? [])}
|
|
81
|
+
value={statusKey} onSave={(v) => save({ status: v })} />
|
|
82
|
+
|
|
83
|
+
Spread it into `InlineSelect`, `Select`, `OptionList` or `Combobox` — it supplies both the
|
|
84
|
+
`options` and the `renderOptionContent` that paints each one.
|
|
85
|
+
|
|
86
|
+
**The obvious call site throws the colour away, silently.** A picker takes `{ value, label }`, so
|
|
87
|
+
every app writes `options.map((o) => ({ value: o.key, label: o.label }))` — and `color` is not in
|
|
88
|
+
that shape, so it never arrives. Nothing errors and nothing looks broken; the value simply renders
|
|
89
|
+
as a coloured chip in the register and as bare grey text the moment someone goes to CHANGE it,
|
|
90
|
+
which is backwards. Recognition matters most in the list you are choosing from, and a filter
|
|
91
|
+
popover of identical grey rows makes the reader match text where they could have matched a colour.
|
|
92
|
+
It is worth grepping for: the mapping is boilerplate, it looks correct, and one CRM had thirteen
|
|
93
|
+
of them.
|
|
94
|
+
|
|
95
|
+
The mapping and the renderer ship together deliberately. Returning only the options would leave
|
|
96
|
+
each caller to write the `renderOptionContent` that reads `data`, and an app that mapped correctly
|
|
97
|
+
but forgot the renderer lands back on grey text with nothing to indicate why.
|
|
98
|
+
|
|
99
|
+
Default `dot`: a picker row is a place to READ a set of choices, and a stack of filled pills is
|
|
100
|
+
heavier than the list describing them. Pass `{ variant: "tonal" }` where the selected value is the
|
|
101
|
+
surface's one prominent status.
|
|
102
|
+
|
|
78
103
|
## Inline edit — the preferred way to edit an existing record
|
|
79
104
|
|
|
80
105
|
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|