@lotics/ui 42.4.0 → 43.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/MIGRATION.md +74 -0
  2. package/docs/catalog.md +38 -6
  3. package/docs/composition.md +45 -3
  4. package/docs/data_entry.md +9 -4
  5. package/package.json +12 -2
  6. package/src/back_button.tsx +4 -1
  7. package/src/badge.tsx +10 -2
  8. package/src/button.tsx +69 -8
  9. package/src/check_circle.tsx +1 -2
  10. package/src/checkbox_input.tsx +2 -2
  11. package/src/choice_list.tsx +2 -2
  12. package/src/color_tokens.ts +27 -3
  13. package/src/colors.web.ts +4 -2
  14. package/src/comments_button.tsx +3 -1
  15. package/src/control_surface.ts +27 -0
  16. package/src/data_grid.tsx +1 -1
  17. package/src/date_calendar.tsx +210 -58
  18. package/src/date_filter.tsx +1 -5
  19. package/src/date_picker.tsx +2 -0
  20. package/src/date_range_selection.ts +18 -0
  21. package/src/date_segments.ts +15 -1
  22. package/src/display_font.ts +27 -0
  23. package/src/display_font.web.ts +31 -0
  24. package/src/file_dropzone.tsx +2 -1
  25. package/src/file_row.tsx +2 -2
  26. package/src/file_thumbnail.tsx +4 -1
  27. package/src/filter_chip.tsx +12 -2
  28. package/src/focus_ring_pressable.tsx +6 -1
  29. package/src/font_family.ts +26 -0
  30. package/src/font_family.web.ts +29 -0
  31. package/src/icon_button.tsx +3 -1
  32. package/src/image_gallery.tsx +1 -1
  33. package/src/index.css +0 -2
  34. package/src/inline_button.tsx +3 -1
  35. package/src/inline_time_picker.tsx +97 -48
  36. package/src/list_item.tsx +109 -11
  37. package/src/locale.tsx +2 -2
  38. package/src/menu_button.tsx +19 -0
  39. package/src/option_list.tsx +8 -0
  40. package/src/pressable_highlight.tsx +20 -8
  41. package/src/pressable_row.tsx +7 -3
  42. package/src/scroll_to_bottom.tsx +3 -0
  43. package/src/slider.tsx +2 -2
  44. package/src/summary.tsx +28 -4
  45. package/src/switch.tsx +3 -1
  46. package/src/table.tsx +46 -4
  47. package/src/text.tsx +32 -11
  48. package/src/text_utils.ts +3 -11
  49. package/src/theme.web.tsx +16 -1
  50. package/src/theme_context.ts +63 -1
  51. package/src/time_columns.tsx +225 -0
  52. package/src/time_options.ts +138 -0
  53. package/src/time_picker.tsx +102 -64
  54. package/src/use_option_list.ts +19 -0
package/MIGRATION.md CHANGED
@@ -4,6 +4,80 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 43.0.0 — a time is PICKED, and there is nothing to type
8
+
9
+ `TimePicker` is no longer a segmented text field. It is the value on a pressable
10
+ control — the bordered surface a `Select` uses — that opens an **hour / minute /
11
+ day-period** picker. `InlineTimePicker` is the same thing on the inline shell.
12
+
13
+ **Why the segments went.** A time was typed, with a picker bolted on as a small
14
+ glyph inside the field, and each half of that was wrong:
15
+
16
+ - **The glyph was undiscoverable, and worse than undiscoverable.** It sat exactly
17
+ where `DateField` puts a display-only calendar icon — same slot, same size,
18
+ same colour — so the one affordance for picking was indistinguishable from
19
+ decoration. A control nobody can identify is not a control.
20
+ - **A day period is not something to spell.** AM/PM is a choice between two named
21
+ things; asking someone to type "PM" is asking them to type an answer to a
22
+ yes/no question. It is now a column.
23
+ - **Composed times cannot cover the clock.** The old list offered whole times at
24
+ a fixed step, so anything between the rungs — a 13:07 cut-off — was reachable
25
+ only by typing, and the list had to splice odd values back in to stay honest.
26
+ Split by UNIT, every minute is offered and that whole class of special case is
27
+ gone.
28
+
29
+ The value is unchanged: canonical 24-hour `"HH:mm"`. The locale still decides 12-
30
+ vs 24-hour, and now also decides the SHAPE — a 24-hour locale gets two columns
31
+ and hours 00–23, a 12-hour locale gets a clock face starting at 12 plus the
32
+ period column. Tab reaches each column once and arrows move within it (roving
33
+ tabindex), so sixty minutes are not sixty tab stops.
34
+
35
+ **To migrate:**
36
+
37
+ - **`TimePicker` drops `onBlur`, `onEscape`, `onIncompleteChange`, `autoFocus`
38
+ and `segmentLabels`.** They all served typed entry. A pick is always a complete
39
+ time, so there is no half-entered state to report and nothing to cancel back
40
+ to; `placeholder` replaces them as the empty-state control. Delete the props —
41
+ the compiler will point at each one.
42
+ - **`InlineTimePicker` gains `locale` and drops `controls`.** It commits when the
43
+ popover closes, not on blur, so a `"buttons"` mode has nothing to confirm.
44
+ - **Add `chooseTime` to any complete `DatePickerLabels` you build** — it names the
45
+ control and doubles as the empty placeholder. A `labels` prop passed to
46
+ `DatePicker` is `Partial`, so only a hand-built full set breaks.
47
+
48
+ **Two kit-wide `OptionList` fixes ride along**, both pre-existing and both
49
+ invisible until a long list met them:
50
+
51
+ - **A chosen option carries `aria-selected`.** It had only the check GLYPH, so
52
+ which option was selected was information available to whoever could see the
53
+ row and to nobody else. It comes with `MenuButton`'s resting `zinc.100`
54
+ highlight — one weight above the `zinc.50` the keyboard/hover row takes — so
55
+ **every** `Select`, `Combobox` and option list now tints its selected row.
56
+ Nothing to change; expect the pixels to move.
57
+ - **An opening list scrolls to its seat.** The initial highlight was seated
58
+ without scrolling to it, so any list longer than its own box opened at the top
59
+ with the current value below the fold.
60
+
61
+ `MenuButton` gains `tabIndex` + `onKeyDown` so a listbox row can hold a roving
62
+ tab stop. Additive.
63
+
64
+ **A pressable `ListItem` presses BESIDE its `right` slot.** The slot is documented as holding
65
+ a control, and a button may not contain one: `<button>` inside `<button>` is invalid HTML and
66
+ buries a second tab stop and a second accessible name inside one control. The anatomy carries
67
+ this rather than a prop opting into safety, so every call site already passing a control there
68
+ is fixed without being touched, and it applies whether or not a slot is passed — `right` is
69
+ routinely conditional, and an anatomy that flipped on that would make two rows in one list
70
+ press differently. The one visible consequence: a DECORATIVE `right` (a badge, a chevron) is no
71
+ longer part of the press target — the row still presses everywhere else.
72
+
73
+ **The pointer cursor is now reserved for controls that ACT** — buttons, links, disclosures.
74
+ Rows, menu options, cards, tabs, field triggers, checkboxes, switches, chips and calendar
75
+ days all show the arrow. No API changes; every affected component is one you already use, so
76
+ the only thing to expect is that the cursor stops changing over most of a screen. The rule and
77
+ its reasoning are in `docs/composition.md` § The pointer cursor. If a component of your own
78
+ needs the pointer, set `CURSOR_ACTION` in its style — and if it is built on the bare
79
+ react-native `Pressable`, note that the pointer is what you get by DEFAULT, from
80
+ react-native-web, unless you set `CURSOR_DEFAULT`.
7
81
  ## 42.4.0 — `Picker` is named by its `FormField`, and its way back to empty has a name
8
82
 
9
83
  Two fixes to the same control, both about a thing on screen that the control did
package/docs/catalog.md CHANGED
@@ -788,7 +788,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
788
788
  shell-owned `actions`).
789
789
  - **`option_list`** — `OptionList`: the ONE shared searchable listbox body every selector
790
790
  opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
791
- typeahead; host it directly in a `Popover`/`Dialog` for a command palette.
791
+ typeahead; host it directly in a `Popover`/`Dialog` for a command palette. A chosen row is
792
+ `selected`: the kit's `zinc.100` resting highlight AND `aria-selected`, one weight above the
793
+ `zinc.50` the keyboard/hover row takes — a check glyph alone said nothing to a screen reader.
794
+ It opens SCROLLED to its seat (the selected row in `search.mode:"none"`, else the first real
795
+ option), which only shows on a list longer than its own box and is the difference between a
796
+ 96-row list opening on your value and opening at the top.
792
797
  - **`combobox`** — COMPOUND single-select editable search: `Combobox` root +
793
798
  `ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`,
794
799
  `useCombobox()`; over the shared option-list engine; browses on focus; no `multi` —
@@ -865,6 +870,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
865
870
  localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
866
871
  `ref.navigateToMonth`. The engine `DatePicker`/`DateFilter` wrap in field chrome — reach
867
872
  for it bare only when the calendar lives permanently on the surface, not behind a field.
873
+ While a range is OPEN (a start, no end), hovering a day fills the span it would close on,
874
+ across both months — including the day under the pointer, which a committed range draws
875
+ dark and a previewed one has nothing else to mark. It shares its open-ness test with the
876
+ click (`previewEndFor` beside `nextRangeSelection`), so the span shown is always the span
877
+ the next click produces; hovering backwards from the start previews just as well.
868
878
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
869
879
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
870
880
  **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
@@ -880,10 +890,25 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
880
890
  end that is still open.
881
891
  - **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
882
892
  (presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
883
- - **`time_picker`** — `TimePicker`: the time-of-day field. Segments (hour / minute / AM-PM),
884
- **12- vs 24-hour derived from `locale`** never from the browser, which is why this is not
885
- an `<input type="time">`. The value is always canonical 24-hour `"HH:mm"`; `locale` changes
886
- only what is displayed and typed.
893
+ - **`time_picker`** — `TimePicker`: the time-of-day FIELD. The value on a pressable
894
+ control (the bordered surface a `Select` uses, so it reads as pickable by the same
895
+ shape as every other field that opens something), and `TimeColumns` behind it.
896
+ **No text entry** — a glyph inside a field is indistinguishable from `DateField`'s
897
+ display-only calendar icon, and a day period is a choice between two named things
898
+ rather than a word to spell. `placeholder` covers the empty state (defaults to the
899
+ `chooseTime` label). The value is always canonical 24-hour `"HH:mm"`; `locale`
900
+ decides what is displayed AND the picker's shape.
901
+ - **`time_columns`** — `TimeColumns`: the picking face — hour, minute, and a day
902
+ period **only where the locale uses one** (a 24-hour locale gets two columns and
903
+ hours 00–23). Every minute is offered, so no time is unreachable and no step has
904
+ to be chosen; each column commits on its own, so the value is always a real time
905
+ and there is no draft to confirm. Roving tabindex per column — Tab reaches a
906
+ column once, arrows move within it, and selection follows focus.
907
+ - **`time_options`** — the columns' vocabulary, RN-free and pure: `decomposeTime` /
908
+ `composeTime` (the split into `displayHour` / `minute` / `pm` and back — a
909
+ 12-hour locale's displayed hour means two different times and only the period
910
+ says which, which is why composing takes both), `hourOptions`, `minuteOptions`,
911
+ `dayPeriodOptions`, `dayPeriodLabels`, `formatTimeOfDay`, `timeToMinuteOfDay`.
887
912
  - **`deadline`** — the COUNTDOWN vocabulary, RN-free and pure: `daysUntil`, `countdownLabel`,
888
913
  `deadlineTone` / `deadlineColor`, `deadlineAnnotation`, `nearestDeadline` (+ `DeadlineLabels`,
889
914
  `DeadlineThresholds`, `Deadline`). Three contracts a screen must not invent for itself — how
@@ -1172,7 +1197,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1172
1197
  ### Lists, tables & registers
1173
1198
 
1174
1199
  - **`list`** — `List`: children separated by hairline `Divider`s.
1175
- - **`list_item`** — `ListItem`: the plain list row.
1200
+ - **`list_item`** — `ListItem`: the plain list row. A pressable row with a `right` slot
1201
+ presses BESIDE it, never around it: the slot holds a control, and a button may not contain
1202
+ one — `<button>` inside `<button>` is invalid HTML and buries a second tab stop and name
1203
+ inside one control. So the surface is a wrapper, the press target is `left` + the text, and
1204
+ the slot sits outside it (the anatomy `InlineEditView`'s `actions` uses). ALWAYS, not only
1205
+ when a slot is passed: `right` is routinely conditional, and an anatomy that flipped on that
1206
+ would make two rows in one list press differently. Nothing to opt into — a decorative
1207
+ `right` simply stops being part of the press target.
1176
1208
  - **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
1177
1209
  (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
1178
1210
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
@@ -396,7 +396,7 @@ shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
396
396
  band + every row's cell widths derive from it and can't drift. ONE hairline under the header; the
397
397
  rows below it Divider-separated. A sortable column shows its `SortHeader` glyph ALWAYS (a neutral
398
398
  up/down glyph when unsorted) so it reads as sortable; pressing cycles none → asc → desc → none.
399
- A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash, no pointer cursor) —
399
+ A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash) —
400
400
  so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
401
401
  registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
402
402
  an empty overlay with no content to derive a name from.
@@ -966,6 +966,26 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
966
966
  a mix of "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker
967
967
  re-prompts, so the destructive verb covers both required anchors and optional adds.
968
968
 
969
+ ## The pointer cursor
970
+
971
+ **Only a control that ACTS ON ITS OWN shows a pointer** — a button, a link, a disclosure.
972
+ Everything else shows the arrow: a row, a menu option, a card, a tab, a field trigger, a
973
+ checkbox, a switch, a chip. The line is not "is it pressable" (nearly everything is) but
974
+ "does pressing it DO something, or does it set a value / open the thing you are already
975
+ looking at".
976
+
977
+ Drawn tightly because a signal on half the screen is not a signal. A pointer everywhere says
978
+ only "this is a web page"; reserved, it marks the few things that act. What carries "you can
979
+ press this" elsewhere is the hover treatment the surface already owns — a row's wash, a
980
+ field's border, a chip's fill — which is what a native list or menu uses too, where the
981
+ cursor stays an arrow throughout.
982
+
983
+ The arrow is never the default and always has to be asked for: react-native-web ships
984
+ `cursor: pointer` inside `Pressable`'s own base style, so a component that says nothing gets
985
+ a pointer. `PressableHighlight` and `FocusRingPressable` set the arrow for everything built
986
+ on them (`CURSOR_DEFAULT`); a component on the bare `Pressable` sets it itself, and the few
987
+ that act set `CURSOR_ACTION` back over the top.
988
+
969
989
  ## Color discipline — solid / tint / ramp + ONE accent
970
990
 
971
991
  Every status / data-viz / accent color comes from a NAMED helper (`@lotics/ui/colors`), never a
@@ -1013,8 +1033,30 @@ When it IS a status, match weight to prominence — lightest → heaviest:
1013
1033
  a surface (a drawer/detail header). Opt IN explicitly and sparingly; one per view, never
1014
1034
  sprinkled through rows.
1015
1035
 
1016
- A register's dense rows read lighter — the row's primary status is `dot`, its drawer twin may go
1017
- tonal. A `Badge` is never a metric value.
1036
+ **`tonal` IS allowed on a register's ONE primary status column, and that is the rule this
1037
+ paragraph used to get wrong.** It read "a register's dense rows read lighter, the row's primary
1038
+ status is `dot`" while `badge.tsx` named "a register's primary Status column" as a legitimate
1039
+ tonal use — two docs, opposite answers, so the choice fell to whoever read which. What "one per
1040
+ view" governs is the number of badge KINDS on a surface, not the number of rows: badging the
1041
+ stage AND the type AND the city is the clutter the rule exists to stop; one status column that
1042
+ happens to render 133 times is one badge.
1043
+
1044
+ Prefer `dot` when the column is secondary or the row already carries a coloured mark. Reach for
1045
+ `tonal` when the status column is what the register is SCANNED by — the tints are the palette's
1046
+ 50-step against its 900-step ink (measured 8.7:1 and 9.5:1, so roughly double the AA floor), and
1047
+ a column of them is the difference between a register that reads as organised and one that reads
1048
+ as a grey spreadsheet. That difference is the commonest "it looks bland" report, and it has a
1049
+ real answer that costs no meaning: make the colour the screen ALREADY earns more present, rather
1050
+ than adding colour somewhere that has to invent a meaning to justify it.
1051
+
1052
+ **A `Badge` in a table cell needs `alignSelf`.** A cell stretches its child on the cross axis, and
1053
+ a `dot` has no ground so nothing shows — swap the variant to `tonal` and the pill becomes a filled
1054
+ block the full width of the column, which reads as a cell state rather than a chip. The kit cannot
1055
+ fix this on the badge: `alignSelf: "flex-start"` means LEFT inside a column-direction cell and TOP
1056
+ inside a row-direction one, so setting it globally would break every badge sitting inline beside
1057
+ text. Pass it at the call site.
1058
+
1059
+ A `Badge` is never a metric value.
1018
1060
 
1019
1061
  ## Typography
1020
1062
 
@@ -123,7 +123,7 @@ dialog save, a debounced autosave).
123
123
  ### Keyboard entry — type → Tab → type
124
124
 
125
125
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
126
- SWAP editor — `InlineNumberInput`, `InlineTimePicker`, `InlineDatePicker` — opens edit mode
126
+ SWAP editor — `InlineNumberInput`, `InlineDatePicker` — opens edit mode
127
127
  immediately with the input focused; commit-on-blur then makes Tab itself the commit, so the chain
128
128
  is type → Tab → type with the next editor already open. Pointer focus never auto-opens (mousedown
129
129
  records "pointer" modality before focus fires — `interaction_modality.ts`), so the click path is
@@ -172,21 +172,26 @@ One per type:
172
172
  - **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
173
173
  ADD/REMOVE a time — the value's own shape, date vs datetime, is the source of truth
174
174
  (`optionalTime` is ignored when `format="datetime"`).
175
- - **`InlineTimePicker`** — a time-only value.
175
+ - **`InlineTimePicker`** — a time-only value, PICKED: the value at rest, the hour /
176
+ minute / period columns in a popover anchored to the field, committing when the
177
+ popover closes. On the POPOVER shell beside `InlineDatePicker`, not the typed one
178
+ — `useInlineEdit` commits when the field blurs, and a picker cannot live there,
179
+ because opening one moves focus into it, which reads as a blur and unmounts the
180
+ editor before the picker can paint.
176
181
  - **`InlineSelect multi`** — the MULTI mode of `InlineSelect` (there's no separate tag component):
177
182
  a tag SET (`value: T[]`), selected tags render as badges inside the standard chip, clicking floats
178
183
  a multi `OptionList` (checkbox rows), CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
179
184
  field.
180
185
 
181
186
  They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
182
- `InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
187
+ `InlineNumberInput`) render **`InlineEditFrame`** driven by the
183
188
  **`useInlineEdit`** hook (the draft buffer, async save, and — for the two that swap — the view⇄edit
184
189
  toggle) — pass a custom input to that pair to join the family. On the frame, **`editing` means an
185
190
  input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
186
191
  host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
187
192
  which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
188
193
  field they would be both a false claim and dead buttons. The **popover** editors (`InlineSelect`,
189
- `InlineMemberSelect`, `InlineDatePicker`) instead compose **`InlineEditView`**
194
+ `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`) instead compose **`InlineEditView`**
190
195
  (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
191
196
  error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
192
197
  either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "42.4.0",
3
+ "version": "43.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -244,6 +244,8 @@
244
244
  "./date_filter": "./src/date_filter.tsx",
245
245
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
246
246
  "./time_picker": "./src/time_picker.tsx",
247
+ "./time_options": "./src/time_options.ts",
248
+ "./time_columns": "./src/time_columns.tsx",
247
249
  "./date_calendar": "./src/date_calendar.tsx",
248
250
  "./dialog": "./src/dialog.tsx",
249
251
  "./drawer": "./src/drawer.tsx",
@@ -275,7 +277,15 @@
275
277
  "./avatar_size": "./src/avatar_size.ts",
276
278
  "./diff_value": "./src/diff_value.tsx",
277
279
  "./diff_mark": "./src/diff_mark.tsx",
278
- "./use_change_set": "./src/use_change_set.ts"
280
+ "./use_change_set": "./src/use_change_set.ts",
281
+ "./display_font": {
282
+ "react-native": "./src/display_font.ts",
283
+ "default": "./src/display_font.web.ts"
284
+ },
285
+ "./font_family": {
286
+ "react-native": "./src/font_family.ts",
287
+ "default": "./src/font_family.web.ts"
288
+ }
279
289
  },
280
290
  "files": [
281
291
  "src",
@@ -3,6 +3,7 @@ import { View } from "react-native";
3
3
  import { Icon } from "./icon";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
+ import { CURSOR_ACTION } from "./control_surface";
6
7
  import { PressableHighlight } from "./pressable_highlight";
7
8
  import { useLoticsLocale } from "./locale";
8
9
 
@@ -44,7 +45,9 @@ export function BackButton(props: BackButtonProps) {
44
45
  accessibilityLabel={accessibilityLabel}
45
46
  userSelect="none"
46
47
  onPress={onPress}
47
- style={{ flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
48
+ // Going back ACTS, exactly as the bare variant's `IconButton` does
49
+ // without this, one component shows two cursors decided by a label.
50
+ style={{ cursor: CURSOR_ACTION, flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
48
51
  >
49
52
  <View style={{ width: 40, height: 40, borderRadius: 999, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
50
53
  <Icon name="chevron-left" size={20} color={colors.zinc[900]} />
package/src/badge.tsx CHANGED
@@ -15,8 +15,16 @@ interface BadgeProps {
15
15
  * Light enough to sit anywhere without shouting. Reach for this by default.
16
16
  * - "tonal": a filled pill — HEAVY, and easy to overuse into clutter. Reserve
17
17
  * it for the ONE prominent status of a surface (a drawer header, a register's
18
- * primary Status column). One per view, a deliberate choice not sprinkled
19
- * through rows or option lists. If in doubt, use `dot`.
18
+ * primary Status column). "One per view" counts badge KINDS, not rows: one
19
+ * status column rendering on every row is ONE badge, while the stage plus the
20
+ * type plus the city is the clutter this warns about. Not for option lists.
21
+ * If in doubt, use `dot`.
22
+ *
23
+ * In a TABLE CELL, pass `style={{ alignSelf: "flex-start" }}`: a cell
24
+ * stretches its child, so a tonal pill fills the whole column otherwise. It
25
+ * cannot be defaulted here — `flex-start` means left in a column-direction
26
+ * parent and TOP in a row-direction one, which would misalign every badge
27
+ * sitting inline beside text.
20
28
  * (StatusGrid/StatusLegend keep their own dot — raw hex coupled to the grid
21
29
  * cells' tint, a separate data-viz concern.)
22
30
  */
package/src/button.tsx CHANGED
@@ -27,6 +27,35 @@ interface ButtonPropsBase {
27
27
  icon?: IconName;
28
28
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
29
29
  color?: ButtonColor;
30
+ /**
31
+ * An action whose DESTINATION carries a brand of its own — an export that
32
+ * produces a spreadsheet, a sign-in with a named provider, a share into a
33
+ * service. Pass that brand's colour and the button wears it: a wash of it as
34
+ * the ground, the colour itself on the label and the mark.
35
+ *
36
+ * **Why this exists rather than a `style` escape hatch.** `ButtonColor` is a
37
+ * closed set on purpose — primary / secondary / danger are the kit's OWN
38
+ * vocabulary, and keeping it small is what lets a reader learn the ladder once.
39
+ * An outside brand genuinely is not on that ladder: it is not a status, not a
40
+ * valence, and not a palette family, so no named variant could ever cover it.
41
+ * The answer is a named CATEGORY with a colour slot, never a hole through
42
+ * which any call site can restyle any button — that is how a design system
43
+ * stops being one.
44
+ *
45
+ * It renders SOLID, with white ink — which is what a brand action looks like
46
+ * everywhere it appears, and what makes the colour recognisable at a glance
47
+ * rather than merely present. A tonal wash was tried first, on the reasoning
48
+ * that a second filled control competes with the surface's one primary; it
49
+ * reads as washed-out rather than restrained, and the hierarchy survives
50
+ * anyway because the primary keeps the last position and the affirmative verb.
51
+ *
52
+ * The cost is real and worth naming: a screen with several of these is a screen
53
+ * with no primary. Reach for it where a destination genuinely has a brand, not
54
+ * to make a button louder.
55
+ *
56
+ * Ignored on `primary` and `danger`, whose own colours are load-bearing.
57
+ */
58
+ brandColor?: string;
30
59
  loading?: boolean;
31
60
  disabled?: boolean;
32
61
  tooltip?: string | UseTooltipOptions;
@@ -59,11 +88,15 @@ export function Button(props: ButtonProps) {
59
88
  onPress,
60
89
  testID,
61
90
  accessibilityLabel,
91
+ brandColor,
62
92
  } = props;
63
93
 
64
94
  // The press waits out an inline commit the same gesture started (pending_commits.ts)
65
95
  // and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
66
96
  // spinner, aria-busy, and the double-press block.
97
+ // Ignored on the variants whose colour MEANS something: a primary that is not
98
+ // the surface's primary colour, or a danger that is not red, would be lying.
99
+ const brand = color === "primary" || color === "danger" || color === "danger-secondary" ? undefined : brandColor;
67
100
  const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
68
101
  const busy = loading || waiting;
69
102
  const disabledOrLoading = disabled || busy;
@@ -77,14 +110,16 @@ export function Button(props: ButtonProps) {
77
110
  ) : (
78
111
  <>
79
112
  {!!icon && (
80
- <Icon size={20} name={icon} color={getButtonIconColor(color, disabledOrLoading)} />
113
+ <Icon size={20} name={icon} color={brand && !disabledOrLoading ? colors.white : getButtonIconColor(color, disabledOrLoading)} />
81
114
  )}
82
115
  {!!title && (
83
116
  <Text
84
117
  numberOfLines={1}
85
118
  size="sm"
86
119
  weight="medium"
87
- color={getButtonTextColor(color, disabledOrLoading)}
120
+ // `inverted` — the kit's own white-on-dark ink, the same token the primary
121
+ // uses, so a brand button and the primary read as one family.
122
+ color={brand && !disabledOrLoading ? "inverted" : getButtonTextColor(color, disabledOrLoading)}
88
123
  userSelect="none"
89
124
  >
90
125
  {title}
@@ -116,11 +151,16 @@ export function Button(props: ButtonProps) {
116
151
  borderRadius: CONTROL_RADIUS,
117
152
  backgroundColor: disabled
118
153
  ? getButtonDisabledBackgroundColor(color)
119
- : pressed
120
- ? getButtonPressedColor(color)
121
- : hovered
122
- ? getButtonHoverColor(color)
123
- : getButtonBackgroundColor(color),
154
+ : brand
155
+ ? // Solid, darkening under the pointer — the same depth ladder the
156
+ // primary walks, so a brand button behaves like a button rather
157
+ // than like a coloured label.
158
+ shade(brand, pressed ? 0.78 : hovered ? 0.88 : 1)
159
+ : pressed
160
+ ? getButtonPressedColor(color)
161
+ : hovered
162
+ ? getButtonHoverColor(color)
163
+ : getButtonBackgroundColor(color),
124
164
  },
125
165
  // Subtle depth on the primary: a soft drop shadow lifts it off the
126
166
  // surface and a whisper of top highlight + a gentle vertical shade (the
@@ -169,10 +209,31 @@ export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): Tex
169
209
  }
170
210
  }
171
211
 
212
+ /**
213
+ * A brand colour at `factor` of its own lightness — the hover/press steps for a
214
+ * solid brand button.
215
+ *
216
+ * Multiplying the channels rather than blending toward black keeps the hue: a
217
+ * dark green stays green as it darkens, where mixing with black walks it toward
218
+ * grey and the recognition goes with it. Returns the input unchanged for a value
219
+ * it cannot parse, so an unusual colour loses its pointer feedback rather than
220
+ * rendering as something else entirely.
221
+ */
222
+ function shade(hex: string, factor: number): string {
223
+ const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
224
+ if (!m || factor === 1) return hex;
225
+ const n = parseInt(m[1], 16);
226
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v * factor));
227
+ return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
228
+ }
229
+
172
230
  function getButtonBackgroundColor(color?: ButtonColor) {
173
231
  switch (color) {
174
232
  case "primary":
175
- return colors.zinc["900"];
233
+ // The themeable role, whose DEFAULT is this exact near-black — so an app
234
+ // that sets no theme renders identical pixels, and one that sets `primary`
235
+ // puts its brand on the surface's single filled button.
236
+ return colors.primary;
176
237
  case "secondary":
177
238
  return colors.zinc["100"];
178
239
  case "danger":
@@ -118,8 +118,7 @@ const styles = StyleSheet.create({
118
118
  tap: {
119
119
  alignItems: "center",
120
120
  justifyContent: "center",
121
- // @ts-ignore rn-web
122
- cursor: "pointer",
121
+ cursor: "auto",
123
122
  },
124
123
  disabled: { opacity: 0.4 },
125
124
  ring: {
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { Pressable } from "react-native";
3
3
  import { Checkbox } from "./checkbox";
4
- import { FOCUS_RING } from "./control_surface";
4
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
5
5
  import { useFocusRing } from "./use_focus_ring";
6
6
  interface CheckboxInputProps {
7
7
  /**
@@ -32,7 +32,7 @@ export function CheckboxInput(props: CheckboxInputProps) {
32
32
  onPress={handlePress}
33
33
  disabled={disabled}
34
34
  {...focusProps}
35
- style={[{ opacity: disabled ? 0.5 : 1, borderRadius: 6 }, focusVisible && { boxShadow: FOCUS_RING }]}
35
+ style={[{ opacity: disabled ? 0.5 : 1, borderRadius: 6, cursor: CURSOR_DEFAULT }, focusVisible && { boxShadow: FOCUS_RING }]}
36
36
  // Use the W3C ARIA props directly: RN-web 0.21 no longer maps
37
37
  // `accessibilityState={{ checked }}` to `aria-checked`, which dropped the
38
38
  // checked state from the accessibility tree (a role="checkbox" with no
@@ -6,7 +6,7 @@ import { Icon } from "./icon";
6
6
  import { Divider } from "./divider";
7
7
  import { composeHandler, useFocusRing } from "./use_focus_ring";
8
8
  import { useHover } from "./use_hover";
9
- import { CONTROL_RADIUS, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
9
+ import { CONTROL_RADIUS, CURSOR_DEFAULT, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
10
10
  import { useAutoGrowHeight } from "./use_auto_grow_height";
11
11
  import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
12
12
  import { useLoticsLocale } from "./locale";
@@ -55,7 +55,7 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
55
55
  // Bleeds like every other washed row, so the option TEXT sits on the
56
56
  // container's edge — level with the question above it, which no longer
57
57
  // has to hand-pad itself to match.
58
- { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
58
+ { cursor: CURSOR_DEFAULT, flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
59
59
  // Selected reads as a persistent tint (zinc-100); an unselected row only
60
60
  // washes on hover (zinc-50). The check below reserves its slot always.
61
61
  selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
@@ -292,8 +292,8 @@ const palette = {
292
292
  };
293
293
 
294
294
  /**
295
- * THE THREE THEMEABLE ROLES are `background`, `border` and `accent` — and
296
- * deliberately nothing else. See `colors.web.ts`, which redefines exactly
295
+ * THE FOUR THEMEABLE ROLES are `background`, `border`, `accent` and `primary` —
296
+ * and deliberately nothing else. See `colors.web.ts`, which redefines exactly
297
297
  * these as CSS variables so an app can carry its own identity.
298
298
  *
299
299
  * The line is drawn at MEANING, not at convenience. A palette family
@@ -327,6 +327,30 @@ export const colors = {
327
327
  * a palette family, so an accent can never overwrite a meaning.
328
328
  */
329
329
  accent: palette.blue["600"],
330
+ /**
331
+ * The PRIMARY action's fill — the one filled button on a surface.
332
+ *
333
+ * Separate from `accent` because their defaults differ and so do their jobs: a
334
+ * brand can own the disc a person is drawn as without owning the shape of a
335
+ * commit, and near-black is a deliberate neutral that works under any brand.
336
+ * Folding them would force an app that themes its avatars to also repaint every
337
+ * CTA, and would turn this near-black into blue for every app that themes
338
+ * nothing.
339
+ */
340
+ primary: palette.zinc["900"],
341
+ /**
342
+ * The brand's TINT — a row's hover, an active filter's ground, a header band.
343
+ *
344
+ * It exists as its own token because it cannot be derived where it is used:
345
+ * `withAlpha`/`tint` do string surgery on an `rgba()`, and on web `accent` is a
346
+ * `var()`, so asking for "accent at 7%" at the call site produces garbage no
347
+ * type would catch. `LoticsThemeProvider` computes it instead, from the literal
348
+ * hex the app handed it — derivation happens where the literal still exists.
349
+ *
350
+ * The default is the neutral wash these surfaces already wore, so an app that
351
+ * themes nothing is pixel-identical.
352
+ */
353
+ accent_wash: palette.zinc["100"],
330
354
  shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
331
355
  };
332
356
 
@@ -357,7 +381,7 @@ export function withAlpha(color: string, alpha: number): string {
357
381
  */
358
382
  export type ColorName = Exclude<
359
383
  keyof typeof colors,
360
- "border" | "border_shadow" | "background" | "accent" | "shadow" | "black" | "white"
384
+ "border" | "border_shadow" | "background" | "accent" | "accent_wash" | "primary" | "shadow" | "black" | "white"
361
385
  >;
362
386
 
363
387
  /**
package/src/colors.web.ts CHANGED
@@ -4,7 +4,7 @@ export { withAlpha, solid, tint, ramp, isColorName, asColorName } from "./color_
4
4
  export type { ColorName } from "./color_tokens";
5
5
 
6
6
  /**
7
- * The token contract, with its FOUR themeable roles indirected through CSS
7
+ * The token contract, with its four themeable roles indirected through CSS
8
8
  * variables so an app can carry its own visual identity.
9
9
  *
10
10
  * Why this file exists at all, rather than a React context: 136 of the kit's 320
@@ -28,7 +28,7 @@ export type { ColorName } from "./color_tokens";
28
28
  * the strictest sense — an app that sets nothing has no `--lotics-*` defined,
29
29
  * every `var()` falls back, and the pixels are identical to before this file.
30
30
  *
31
- * ONLY these three. The palette families stay literal, and must: `withAlpha`,
31
+ * ONLY these five. The palette families stay literal, and must: `withAlpha`,
32
32
  * `tint` and `ramp` do string surgery on an `rgba()` to derive a wash, and a
33
33
  * `var()` handed to them would produce garbage no type would catch. Keeping the
34
34
  * themeable set to chrome keeps those functions total — the maintainability
@@ -40,4 +40,6 @@ export const colors = {
40
40
  background: `var(--lotics-background, ${contract.background})`,
41
41
  border: `var(--lotics-border, ${contract.border})`,
42
42
  accent: `var(--lotics-accent, ${contract.accent})`,
43
+ primary: `var(--lotics-primary, ${contract.primary})`,
44
+ accent_wash: `var(--lotics-accent-wash, ${contract.accent_wash})`,
43
45
  };
@@ -2,7 +2,7 @@ import { StyleSheet, type GestureResponderEvent } from "react-native";
2
2
  import { colors } from "./colors";
3
3
  import { Icon } from "./icon";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
- import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
5
+ import { CURSOR_ACTION, ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
6
6
  import { Text } from "./text";
7
7
  import { useLoticsLocale } from "./locale";
8
8
 
@@ -105,6 +105,8 @@ export function CommentsButton(props: CommentsButtonProps) {
105
105
 
106
106
  const styles = StyleSheet.create({
107
107
  button: {
108
+ // Opening the thread ACTS — see `CURSOR_ACTION`.
109
+ cursor: CURSOR_ACTION,
108
110
  flexDirection: "row",
109
111
  alignItems: "center",
110
112
  // Tight: the glyph and its number are ONE word, not two things in a row.
@@ -159,3 +159,30 @@ export function chipSurfaceStyle(
159
159
  boxShadow: selected ? `0 0 0 1px ${colors.zinc[900]}` : undefined,
160
160
  };
161
161
  }
162
+
163
+ /**
164
+ * Which controls show a POINTER, and which show the arrow.
165
+ *
166
+ * **Only a control that ACTS ON ITS OWN gets the pointer** — a button, a link, a
167
+ * disclosure. Everything else takes the arrow: a row, a menu option, a card, a
168
+ * tab, a field trigger, a checkbox, a switch, a chip. The line is not "is it
169
+ * pressable" (nearly everything here is) but "does pressing it DO something, or
170
+ * does it set a value / open the thing you are already looking at".
171
+ *
172
+ * The reason to draw it tightly is that a signal on half the screen is not a
173
+ * signal. A pointer everywhere says only "this is a web page"; reserved, it
174
+ * marks the handful of things that act. What carries "you can press this"
175
+ * elsewhere is the hover treatment each surface already owns — a row's wash, a
176
+ * field's border, a chip's fill — which is the affordance a native list or menu
177
+ * uses too, where the cursor stays an arrow throughout.
178
+ *
179
+ * **The arrow is never the default and always has to be asked for.**
180
+ * react-native-web ships `cursor: pointer` inside `Pressable`'s own base style,
181
+ * so a component that says nothing gets a pointer. `PressableHighlight` and
182
+ * `FocusRingPressable` set the arrow for everything built on them, and a
183
+ * component on the bare `Pressable` sets it itself; the few that act set
184
+ * {@link CURSOR_ACTION} back over the top.
185
+ */
186
+ export const CURSOR_DEFAULT = "auto";
187
+ /** For a button, a link, a disclosure — see {@link CURSOR_DEFAULT}. */
188
+ export const CURSOR_ACTION = "pointer";