@lotics/ui 5.9.0 → 5.10.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 (97) hide show
  1. package/AGENTS.md +127 -44
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_dossier.tsx +5 -4
  4. package/examples/tpl_pipeline.tsx +400 -0
  5. package/examples/tpl_record.tsx +5 -4
  6. package/examples/tpl_shifts.tsx +7 -5
  7. package/examples/tpl_task_board.tsx +473 -0
  8. package/examples/tpl_tasks.tsx +440 -0
  9. package/package.json +10 -5
  10. package/src/accordion.tsx +1 -1
  11. package/src/agent_progress.tsx +1 -1
  12. package/src/agent_run.tsx +1 -1
  13. package/src/breakdown.tsx +2 -2
  14. package/src/button.tsx +12 -3
  15. package/src/calendar/calendar_view.tsx +10 -10
  16. package/src/calendar/month_view.tsx +30 -11
  17. package/src/calendar/time_grid_view.tsx +25 -7
  18. package/src/card_select_item.tsx +11 -13
  19. package/src/check_circle.tsx +88 -0
  20. package/src/checkbox_input.tsx +5 -1
  21. package/src/chip.tsx +1 -1
  22. package/src/chip_group.tsx +1 -1
  23. package/src/column_filter.tsx +3 -2
  24. package/src/combobox.tsx +84 -219
  25. package/src/confidence.tsx +14 -3
  26. package/src/control_surface.ts +22 -7
  27. package/src/data_grid.tsx +159 -0
  28. package/src/date_calendar.tsx +46 -23
  29. package/src/date_field.tsx +10 -2
  30. package/src/date_filter.tsx +5 -9
  31. package/src/date_picker.tsx +78 -26
  32. package/src/date_picker_value.ts +11 -0
  33. package/src/date_range_filter_field.tsx +6 -4
  34. package/src/drawer.tsx +1 -1
  35. package/src/file_dropzone.tsx +5 -0
  36. package/src/file_gallery_modal.tsx +43 -42
  37. package/src/file_preview.web.tsx +25 -11
  38. package/src/file_preview_types.ts +9 -0
  39. package/src/file_row.tsx +5 -1
  40. package/src/file_rows.tsx +6 -2
  41. package/src/file_thumbnail.tsx +13 -4
  42. package/src/file_thumbnail_grid.tsx +5 -0
  43. package/src/files_editor.tsx +232 -0
  44. package/src/filter_chip.tsx +25 -12
  45. package/src/focus_ring_pressable.tsx +34 -0
  46. package/src/form_picker.tsx +3 -3
  47. package/src/gantt/gantt_view.tsx +25 -7
  48. package/src/heatmap.tsx +5 -1
  49. package/src/icon_button.tsx +2 -2
  50. package/src/image_gallery.tsx +8 -10
  51. package/src/index.css +8 -12
  52. package/src/inline_date_picker.tsx +11 -11
  53. package/src/inline_edit.tsx +22 -6
  54. package/src/inline_member_select.tsx +1 -1
  55. package/src/inline_select.tsx +9 -8
  56. package/src/inline_text_input.tsx +4 -1
  57. package/src/link.tsx +11 -1
  58. package/src/link_button.tsx +1 -1
  59. package/src/list_item.tsx +2 -2
  60. package/src/member_select.tsx +9 -4
  61. package/src/menu_button.tsx +2 -2
  62. package/src/menu_list_item.tsx +2 -2
  63. package/src/number_input.tsx +11 -4
  64. package/src/option_list.tsx +211 -0
  65. package/src/peek.tsx +1 -1
  66. package/src/picker.tsx +24 -213
  67. package/src/pressable_highlight.tsx +42 -23
  68. package/src/radio_picker.tsx +2 -2
  69. package/src/range_slider.tsx +35 -7
  70. package/src/react_native.d.ts +3 -0
  71. package/src/scroll_to_bottom.tsx +1 -1
  72. package/src/section.tsx +7 -2
  73. package/src/segmented_control.tsx +2 -2
  74. package/src/select.tsx +299 -0
  75. package/src/sort_header.tsx +1 -1
  76. package/src/sources.tsx +1 -1
  77. package/src/spreadsheet_view.tsx +43 -17
  78. package/src/status_grid.tsx +4 -3
  79. package/src/stepper.tsx +1 -1
  80. package/src/switch.tsx +5 -1
  81. package/src/switch_button.tsx +1 -1
  82. package/src/switcher.tsx +1 -1
  83. package/src/table.tsx +5 -1
  84. package/src/tabs.tsx +2 -2
  85. package/src/text_input_field.tsx +15 -4
  86. package/src/time_picker.tsx +11 -4
  87. package/src/timeline.tsx +1 -1
  88. package/src/use_focus_ring.ts +80 -0
  89. package/src/use_hover.ts +26 -0
  90. package/src/use_list_keyboard_nav.test.ts +71 -0
  91. package/src/use_list_keyboard_nav.ts +52 -5
  92. package/src/use_option_list.test.ts +193 -0
  93. package/src/use_option_list.ts +354 -0
  94. package/src/command_menu.tsx +0 -205
  95. package/src/picker_menu.tsx +0 -355
  96. package/src/tag_input.tsx +0 -203
  97. package/src/time_field.tsx +0 -297
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ every app inherits), never inline a one-off `View`/`Text` rebuild — that forfe
8
8
  typeahead, async search, virtualization, and a11y the primitive already ships.
9
9
 
10
10
  - Import per module: `import { Combobox } from "@lotics/ui/combobox"`.
11
- - Floating content (`Dialog` · `Popover` · `Tooltip` · `Alert` · `CommandMenu`) needs a
11
+ - Floating content (`Dialog` · `Popover` · `Tooltip` · `Alert` · `OptionList`) needs a
12
12
  `PortalHost` at the app root.
13
13
  - RN-Web only: `View`/`ScrollView` from `react-native`, the `Text` primitive (no raw
14
14
  `div`/`span`, no raw `fontSize`/`fontWeight`); styles are RN objects.
@@ -30,13 +30,15 @@ Pick by capability, not by name. (→ the source file for the API.)
30
30
  action — Clear / Select all), `Chip` (dismissible facet chip). A button is never a raw
31
31
  `Pressable`. For a link OUT (a URL / record / document) use `Link` — underline+blue, the
32
32
  destination signal; the opposite of `LinkButton` (a quiet in-app action, never underlined).
33
- - **Pick from a list** — `Picker` (known options, native typeahead, single/multi/custom-render,
34
- no search box). Search-as-you-type / async / create-new → `Combobox`. A selectable card row →
35
- `CardSelectItem`. For a SELECT-FIELD picker, render each option as its colored chip
36
- (`renderOptionContent={(o) => <OptionBadge value={o} />}`).
37
- - **Pick member(s)** `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
33
+ - **Pick from a list** — `Picker` (native `<select>`, plain label-only single, native typeahead)
34
+ or `Select` (the rich one — custom-rendered options, single/multi, select-all). Search-as-you-type
35
+ / async / create-new `Combobox`. A selectable card row `CardSelectItem`. For a SELECT-FIELD
36
+ picker, render each option as its colored chip on `Select`
37
+ (`renderOptionContent={(o) => <OptionBadge value={o} />}`). `Select`/`Combobox` all open
38
+ the one shared `OptionList` body; `Picker` is the only one that's a native dropdown.
39
+ - **Pick member(s)** — `MemberSelect` (a `Select` that renders each option as a `MemberChip`,
38
40
  single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
39
- Don't re-wire `Picker` + `renderOptionContent` + a directory by hand. To edit a `select_member`
41
+ Don't re-wire `Select` + `renderOptionContent` + a directory by hand. To edit a `select_member`
40
42
  field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
41
43
  - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
42
44
  ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
@@ -47,16 +49,37 @@ Pick by capability, not by name. (→ the source file for the API.)
47
49
  badge) — never hand-map option-key → color. Feed it a resolved option (`useFieldOptions` for a
48
50
  picker option, or `byKey(readSelect(cell)[0]?.key)` for a stored value); multi-select wraps to
49
51
  one badge each; a missing/unknown color token degrades to neutral.
50
- - **Tags / multi-select chip box** — `TagInput` (chips + an Add-popover checklist + create), NOT
51
- `Combobox multi` (see §Data entry).
52
+ - **Tags / multi-value chip box** — `Select multi` whose `renderSelected` returns a removable
53
+ `<Chip onDismiss={remove}>` (with `searchable` + `allowCustom`). The chip box is COMPOSED, not a
54
+ separate control — there's no `display` mode; `renderSelected` is the seam (see §Data entry).
52
55
  - **Text & form** — `TextInputField`, `NumberInput`, `SearchInput`; wrap with `FormField`;
53
56
  `Checkbox`, `Switch`, `RadioPicker`; dates via `DatePicker` / `DateRangeFilterField`, times
54
57
  via `TimePicker`.
55
58
  - **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
56
59
  `InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
57
60
  `InlineTimePicker` (see §Data entry).
58
- - **Tabular data** — `Table` (columns defined once; sortable headers via `SortHeader`; paired
59
- with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
61
+ - **Tasks / to-dos** — there is NO Task component (a task row varies too much app-to-app to abstract
62
+ well a Reminders row and a Linear column-grid share almost nothing). Compose the row directly:
63
+ `CheckCircle` (the completion ring — fills with a spring, distinct from the square `Checkbox`) + an
64
+ inline-editable title (`InlineTextInput` with `struck` for done) + whatever meta cells you add
65
+ (`OptionBadge` dot tags, `InlineDatePicker`, `InlineMemberSelect`). The one reusable primitive is
66
+ `CheckCircle`; the rest is composition — see `tpl_tasks` (quick list) and `tpl_task_board` (columns).
67
+ - **Tabular data — pick by SCALE + intent.** Two columnar shapes, and the choice is about data size:
68
+ - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
69
+ headers via `SortHeader`; paired with `Pagination`) + read-only rows that open a `Drawer` to edit.
70
+ It scales by PAGING — renders one page, never the whole set — so you FILTER + search, you don't
71
+ group. Worked example: `tpl_dossier`. Never an HTML `<table>` or a `.map` of rows.
72
+ - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view) — the
73
+ `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors (ANY field — a
74
+ column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`, so `InlineMemberSelect` /
75
+ `InlineDatePicker` / `InlineNumberInput` / `InlineSelect` / a borderless `Select multi` for tags / colour-dot `OptionBadge`).
76
+ `DataGrid` owns the sortable header + collapsible grouped sections + aligned rows + an optional
77
+ per-row `leading` slot (a `CheckCircle`); YOU own the data, the sort/group/filter/collapse STATE
78
+ (`cycleSort` + `sortBy`, a `collapsed` Set), the toolbar (`SearchInput` + `FilterChip`s), and the
79
+ per-group add row (`renderGroupFooter`, aligned with the exported `gridRowStyle` + the column
80
+ widths). It renders ALL rows (no virtualization), so it's only for sets small enough to hold in
81
+ view — at 10k+ it lags, and grouping + pagination/infinite don't compose; use the register instead.
82
+ Worked examples: `tpl_task_board` (with a `CheckCircle` leading), `tpl_pipeline` (no leading).
60
83
  - **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
61
84
  (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG set — no
62
85
  recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
@@ -71,14 +94,20 @@ Pick by capability, not by name. (→ the source file for the API.)
71
94
  (bulk-select bar).
72
95
  - **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
73
96
  `Counter`), `Chip`.
74
- - **Overlays** — `Dialog`, `Drawer` (+ `DrawerFooter`), `Popover`, `Tooltip`, `CommandMenu`,
97
+ - **Overlays** — `Dialog`, `Drawer` (+ `DrawerFooter`), `Popover`, `Tooltip`, `OptionList`
98
+ (the searchable list body — host it in a `Popover`/`Dialog` for a command palette),
75
99
  `Alert` (the blocking confirm).
76
100
  - **Status / feedback** — `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`,
77
101
  `CompletionState`, `ActivityIndicator` / `Loading`, `Skeleton`.
78
- - **Files** — `FileDropzone`, `FileRows` (batteries-included file LIST: tap a row → built-in
79
- full-screen gallery, menu = Download · Open-external · Remove; the default "here are some files"
102
+ - **Files** — `FilesEditor` (THE all-in-one attachment field: an upload-aware grid + a toolbar below
103
+ it that swaps into a batch SELECT mode, full-screen preview, download/share, and Alert-confirmed
104
+ remove — the host only owns `files` + wires `onAdd`/`onRemove`; mirrors the frontend
105
+ `cell_files_editor`. Reach for this first for "manage a record's attachments"), `FileDropzone`,
106
+ `FileRows` (batteries-included file LIST: tap a row → built-in
107
+ full-screen gallery, with a per-row trailing ⋯ menu = Download · Open-external · Remove; the default "here are some files"
80
108
  surface), `FileGrid` (the upload-aware grid: completed files + a live upload
81
- queue in one surface — use this for any add-files screen), `FileThumbnail` / `FileThumbnailGrid`
109
+ queue in one surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own
110
+ the chrome), `FileThumbnail` / `FileThumbnailGrid`
82
111
  (square tiles, display-only), `UploadingThumbnail` (the single in-flight tile FileGrid renders —
83
112
  reach for it only when hand-rolling a non-grid upload layout), `FileRow` (a horizontal file/document
84
113
  LINE — badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge / action
@@ -120,7 +149,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
120
149
  | a RELATED record (pick or make) | **find-or-create** (`Combobox allowCustom`) | one control covers both |
121
150
  | REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
122
151
  | CHARGES that bill onto documents | **billing** (`tpl_billing`) | the invoice document is the unit |
123
- | a multi-value TAG field | **`TagInput`** | a chip box, not a search input |
152
+ | a multi-value TAG field | **`Select multi`** (`renderSelected` → `Chip`) | chips composed, not a separate control |
124
153
  | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
125
154
  | a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
126
155
  | FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
@@ -133,10 +162,12 @@ form mode or a preview↔edit card — make each VALUE inline-editable: it reads
133
162
  hover tints it (no pencil — that shifts), click swaps the input in **at the same height** (zero
134
163
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
135
164
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
136
- (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats a
137
- `Picker` menu in a popover so the row never grows; `renderValue` renders the RESTING value as a
165
+ (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
166
+ `OptionList` in a popover so the row never grows; `renderSelected` renders the RESTING value as a
138
167
  node — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
139
- member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` · `InlineTimePicker` — all
168
+ member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` (`format="datetime"` for
169
+ always-on time; `optionalTime` to let the user ADD/REMOVE a time — the value's own shape, date vs
170
+ datetime, is the source of truth) · `InlineTimePicker` — all
140
171
  on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
141
172
  saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
142
173
  without losing the edit. Pair with `DetailRow` (label left, inline value right). Not every field
@@ -187,13 +218,15 @@ missing methods (`Alert.alert` listing each) rather than a vague "incomplete." A
187
218
  **deposit** is its own card and its own receipt — never folded into the total due. Composition over
188
219
  `Card` + `NumberInput` + `Picker` + `Badge`; no new primitive.
189
220
 
190
- ### Tag field — `TagInput`, NOT `Combobox multi`
191
- A tag field's resting state should be a tidy CHIP BOX: `TagInput` is a bordered box of removable
192
- chips + an Add affordance; searching/creating happens in a POPOVER (a searchable, checkable list +
193
- an optional `allowCreate` row), never an inline token input. `Combobox multi` squeezes a
194
- `TextInput` among the chips right for a SEARCH field that accumulates picks, wrong for a chip box
195
- you occasionally add to. One owner per shape: chips-you-edit `TagInput`; search-that-accumulates
196
- `Combobox multi`.
221
+ ### Tag / multi-value field — `Select multi`
222
+ A tag field's resting state should be a tidy CHIP BOX and that's just a multi `Select` whose
223
+ `renderSelected` returns a removable `<Chip onDismiss={remove}>`. There's NO separate component and no
224
+ `display` mode: `renderSelected(item, { remove })` composes the anchor render a `Chip` with `remove`
225
+ for the ✕, or a plain `OptionBadge`/`MemberChip`/custom pill that ignores it; `renderOptionContent`
226
+ renders the menu rows. `searchable` adds the filter field, `allowCustom` the create row. Borderless for
227
+ a grid cell? Pass a `style`, never a `variant`. `Combobox` is the SINGLE-value sibling — for a search
228
+ that emits one pick at a time and renders the selection elsewhere, use `reflectSelection={false}`
229
+ (see `tpl_*`).
197
230
 
198
231
  ### Disposition — lifecycle status is ASYMMETRIC by phase
199
232
  When a status is an OPEN default plus terminal OUTCOMES the user decides (Lead → Closed/Lost, Draft
@@ -207,6 +240,10 @@ quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each state a colored dot
207
240
  marked + disabled), not a generic text menu. A composition (Badge + Button + Popover/MenuButton).
208
241
 
209
242
  ### Attachments — a full add / preview / DELETE field
243
+ **Default: `<FilesEditor files onAdd onRemove>`** — it bundles the grid + a toolbar (Upload · Select ·
244
+ Download all) + a batch SELECT mode + the gallery + confirmed-remove; the host only owns `files` and
245
+ wires `onAdd` (picked → its upload) / `onRemove`. Reach for the lower-level pieces below only when you
246
+ need custom chrome:
210
247
  Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
211
248
  accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`** ABOVE
212
249
  the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add more"
@@ -218,8 +255,8 @@ hand-map an upload to a `FileThumbnail`. Localize the labels with `labels.upload
218
255
  `UploadStatusLabels`; the Lotics `@lotics/ui-internal` adapter already injects vi/en). Make it CRUDable
219
256
  by wiring its callbacks: `onFilePress`
220
257
  → set a `number|null` index that drives `<FileGalleryModal files activeIndex onIndexChange>` — a
221
- FULL-SCREEN viewer with a toolbar (filename · counter · ⋯actions (download, optional
222
- `onOpenExternal`/`onRemove`) · close-✕), prev/next, ESC, and rotate; `onDisplayRemove`/`onUploadRemove`
258
+ FULL-SCREEN viewer with a toolbar (filename · counter · inline action buttons — download, optional
259
+ `onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate; `onDisplayRemove`/`onUploadRemove`
223
260
  → drop the file / cancel the upload (the grid renders a ✕ on each tile automatically); `onRetry` /
224
261
  `onRetryAll` for failed uploads. In a sandboxed custom-code app the toolbar's "open in new tab" can't
225
262
  pop a window — pass `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the
@@ -414,7 +451,8 @@ used to carry now reads structurally — **provenance** is an uppercase microlab
414
451
  (`PROPOSED` · `MATCH` · `MISMATCH` · `SUGGESTED EDIT` · `QUESTION`), and the agent's **reasoning** is
415
452
  a left-ruled margin note (a hairline rule + muted text), quoted apart from the facts and the human's
416
453
  controls — while **status/severity/diffs use functional colour** the way the rest of the kit does:
417
- - **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
454
+ - **Confidence** — a 3-tick meter + the full phrase ("High confidence" / "Medium" / "Low"), emerald /
455
+ amber / zinc by level; `labels` to translate (one phrase per level).
418
456
  - **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
419
457
  emerald on-track (+ stacking order, most severe first).
420
458
  - **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
@@ -469,11 +507,35 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
469
507
  (Tabs/SegmentedControl/RadioPicker) keep ONE stop at `0`, the rest `-1`. And NEVER let a FOCUSED
470
508
  control unmount — a conditional pointer affordance that vanishes on use (e.g. an "apply suggested
471
509
  value" pill shown only while a field is empty) must be `tabIndex={-1}`, or the browser drops focus
472
- to `<body>` and the next Tab jumps to the page's first focusable. A mouse-opened popover trigger
473
- (Picker/Combobox/InlineSelect/InlineDatePicker) gets no `:focus-visible` ring, so it wears
474
- **`ACTIVE_RING`** (`control_surface.ts`, a `0 0 0 2px zinc-900` box-shadow mirroring the focus
475
- outline) on its open state reuse that token, don't hand-roll a thin 1px edge. Full rules + the
476
- keyboard test: `docs/accessibility.md` Focus & tab order.
510
+ to `<body>` and the next Tab jumps to the page's first focusable. Full rules + the keyboard test:
511
+ `docs/accessibility.md` Focus & tab order.
512
+ - **Focus rings are per-component, never global.** There is NO global focus CSS — `index.css` only
513
+ resets the native outline. Every interactive control paints its OWN ring; nothing rings unless it
514
+ opts in. Three ways, pick by base:
515
+ - **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on keyboard
516
+ focus; the default for a raw interactive element (cell, tile, nav button, menu option). Forwards
517
+ all `PressableProps` (onPress, accessibilityRole/Label/State, ref, style). Reach for this before
518
+ hand-rolling a Pressable + the hook.
519
+ - **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible` from
520
+ its style-fn / children for a bespoke treatment, e.g. `CardSelectItem` which rings on hover/press/focus).
521
+ - **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom surfaces.
522
+ Returns `{ focusVisible, focused, focusProps }`; spread `focusProps`, apply
523
+ `focusVisible && { boxShadow: FOCUS_RING }` (comma-join any existing box-shadow). `useFocusRing({ always: true })`
524
+ for text-like inputs/selects (ring on ANY focus, as browsers do for typing-capable fields); plain
525
+ controls omit it (keyboard-only).
526
+ The ring is the shared **`FOCUS_RING`** token (`@lotics/ui/control_surface`, `0 0 0 2px zinc-900`). A
527
+ mouse-opened popover trigger (Select/Combobox/InlineSelect/InlineDatePicker) wears the SAME token on
528
+ its open state so it reads identically to a keyboard-focused control. **Coverage is a contract:** EVERY
529
+ interactive control must ring (a focusable control with no focus treatment is a bug — the per-Pressable
530
+ audit is the gate); a surface that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
531
+ - **A trigger's hover/focus is a contract too — build it on `PressableHighlight`, never a raw `Pressable`.**
532
+ Every popover/menu/select anchor (`Select`, `FilterChip`, `Switcher`, the inline editors, a custom
533
+ `PopoverTrigger`) rides `PressableHighlight` (or `FocusRingPressable`): it paints the hover **wash** +
534
+ focus ring AROUND the caller's `style`, so a borderless in-cell override (`style={{ borderColor:
535
+ "transparent" }}` on a `Select`) keeps the wash and reads like its inline neighbours. A raw `Pressable`
536
+ that hand-rolls its own hover is the bug — a caller's `borderColor` then erases the affordance. Layer the
537
+ hover-border BEFORE `style` (a transparent edge suppresses it → wash only) and the open ring (`FOCUS_RING`)
538
+ on the open state.
477
539
  - **Cards are banded — and composable** (all from `@lotics/ui/card`):
478
540
  ```tsx
479
541
  <Card style={{ padding: 0 }}>
@@ -548,7 +610,7 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
548
610
  · `ChipGroup` is THE one-of-N lens (≤ ~10 options the user flips between; include "All"; counts in
549
611
  the label). Filters ONE list to a SUBSET — including by process stage / lifecycle state.
550
612
  · `FilterChip` is THE secondary-dimension filter — a compact chip that opens a composed popover
551
- editor (`PickerMenu multi` / `RangeSlider` / `Counter` / date range). Single-select closes on
613
+ editor (`OptionList multi` / `RangeSlider` / `Counter` / date range). Single-select closes on
552
614
  pick via the `{({close}) => …}` render prop.
553
615
  · `ColumnFilter` is the TYPED column filter — give it a `FilterableColumn` (`text` / `number` /
554
616
  `select`) + a controlled `ColumnFilterValue` and it renders the right editor (contains / range /
@@ -611,13 +673,30 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
611
673
  `tpl_inventory` (threshold actions) · `tpl_dossier` (high-volume paginated register) ·
612
674
  `tpl_approvals` (verdicts: bulk + over-policy `Dialog`) · `tpl_reconcile` (pair matching) ·
613
675
  `tpl_dispatch` (capacity allocation) · `tpl_batch` (compose from parts) · `tpl_pick` (guided
614
- `ScanField` run) · `tpl_allocate` (`RemainderMeter` split) · `tpl_run` (preview → resolve → post).
676
+ `ScanField` run) · `tpl_allocate` (`RemainderMeter` split) · `tpl_run` (preview → resolve → post) ·
677
+ `tpl_tasks` (the QUICK LIST — Apple-Reminders shape for personal lists: `CheckCircle` rows you tick +
678
+ expand in place to edit (`Inline*`, stable row — the meta summary never shifts on expand), colour-dot
679
+ tags, an attachments field (`FileThumbnailGrid` → tap a tile to the gallery for download + confirmed
680
+ remove + an `Add attachment` `pickFiles` button), search + a clearable group-by (✕ → ungrouped) + tag
681
+ filter chips, and a primary Draft-from-notes CTA `Composer → AgentRun → ChangeReview`) ·
682
+ `tpl_task_board` (the COLUMNS shape: a search · group-by · filter toolbar over a grouped, sortable
683
+ grid of inline-editable cells — assignee/due/status/tags set directly, a files cell (thumbnail glance →
684
+ popover grid → gallery), per-group add rows that pre-set the group's field, and the same Draft-from-notes
685
+ CTA) · `tpl_pipeline` (the SAME inline-managed grouped-table shape, a second domain — deals grouped by
686
+ stage with a money-value cell + owner + close-date edited inline, its own Draft-from-notes CTA drafting
687
+ deals; proof the shape generalizes beyond tasks). All three: filters/search left, primary CTA right, one
688
+ row; group-by holds only real dimensions, ✕ clears to ungrouped (no "Nothing" option). **`tpl_task_board` + `tpl_pipeline` are the inline-managed
689
+ grouped table — MODERATE data you manage in view (group/sort/filter/edit-in-place, renders all
690
+ rows). For thousands+ you BROWSE, that's `tpl_dossier`'s paginated register, not this.** Both build
691
+ on the `DataGrid` primitive (the grid shape, extracted once these two uses confirmed it); each owns
692
+ only its data, toolbar, columns and per-group add. There is NO Task component — a task ROW varies
693
+ too much to bake into one, so rows compose `CheckCircle` + `InlineTextInput` + inline-editor cells.
615
694
  - **Data capture** — `tpl_intake` (multi-fieldset form + attachments) · `tpl_order` (the
616
695
  transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
617
696
  (charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
618
697
  live total, status, inline-gated issue; validated receipt; separate deposit) · `tpl_quick`
619
698
  (quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record` (inline edit + Stage
620
- disposition + `TagInput` + Documents grid; `tpl_record_plain` = card-less).
699
+ disposition + `Select` tags + Documents grid; `tpl_record_plain` = card-less).
621
700
  - **Records & lookup** — `tpl_detail` (full record + tabs) · `tpl_directory` (searchable register) ·
622
701
  `tpl_timeline` (audit feed).
623
702
  - **Planning & time** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
@@ -649,22 +728,23 @@ option_badge (OptionBadge — a select value as its configured colored badge) ·
649
728
  member_chip (MemberChip — avatar + name; the universal person render) ·
650
729
  member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
651
730
  status_badge · button · icon_button · link · link_button · chip · tabs · segmented_control ·
652
- picker · combobox · tag_input (TagInputchip box + Add-popover; for tags, not Combobox multi) ·
731
+ picker (native `<select>`, plain label-only single) · select (Select — rich/custom-rendered, single/multi, select-all, chips via `renderSelected` + `searchable` + `allowCustom` — the tag field is just a multi Select; opens `OptionList`) · option_list (OptionListthe ONE shared searchable listbox body every selector opens: single/multi, optional internal search, create row, keyboard + native-`<select>` typeahead; host it directly in a `Popover`/`Dialog` for a command palette) · combobox (single-select editable search input; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
653
732
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
654
733
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
655
734
  inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
656
735
  inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
657
- `InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderValue` for a chip/badge at rest) ·
658
- list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
736
+ `InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderSelected` for a chip/badge at rest) ·
737
+ list · list_item · menu_button · menu_list_item · detail_row · pressable_row ·
738
+ check_circle (CheckCircle — the completion ring: an empty ring that springs to a filled check when done, distinct from the square checkbox; the task/to-do/checklist toggle. Compose task rows directly, no Task component) · action_menu ·
659
739
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
660
740
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
661
- sort_header · table · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
741
+ sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
662
742
  step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
663
- command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
743
+ alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
664
744
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
665
745
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
666
746
  status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_meter · allocation_row ·
667
- scan_field · file_dropzone · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview ·
747
+ scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done) + built-in gallery + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview ·
668
748
  file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
669
749
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
670
750
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
@@ -678,4 +758,7 @@ finding (Finding — one ranked briefing/audit insight) · discrepancy (Discrepa
678
758
  disagrees across sources) · triage_row (TriageRow — an item the agent classified + routed) ·
679
759
  scored_option (ScoredOption — an AI-ranked candidate) ·
680
760
  format_money · format_date · colors (solid · tint · ramp · ColorName ·
681
- isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
761
+ isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback) ·
762
+ use_focus_ring (useFocusRing — keyboard-aware focus state for painting a control's own ring; see Focus rings) ·
763
+ focus_ring_pressable (FocusRingPressable — a Pressable that rings on keyboard focus; the raw-control default) ·
764
+ control_surface (CONTROL_HEIGHT · CONTROL_RADIUS · FOCUS_RING · chipSurfaceStyle — the shared control-surface tokens).
@@ -17,7 +17,7 @@ import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
17
17
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
18
18
  import { FormField } from "@lotics/ui/form_field";
19
19
  import { KPIStrip } from "@lotics/ui/kpi_strip";
20
- import { PickerMenu } from "@lotics/ui/picker_menu";
20
+ import { OptionList } from "@lotics/ui/option_list";
21
21
  import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
22
22
  import { RangeSlider, rangeSummary } from "@lotics/ui/range_slider";
23
23
  import { PressableRow } from "@lotics/ui/pressable_row";
@@ -255,10 +255,10 @@ export function TplApprovals() {
255
255
  onValueChange={(t) => { setTab(t); setOpenId(null); }}
256
256
  />
257
257
  <FilterChip label="Requester" summary={reqSummary} onClear={() => setReqSel([])} clearLabel="Clear requester filter">
258
- <PickerMenu multi enableSelectAll options={REQUESTERS} value={reqSel} onValueChange={setReqSel} />
258
+ <OptionList search={{ mode: "none" }} multi options={REQUESTERS} value={reqSel} onValueChange={setReqSel} />
259
259
  </FilterChip>
260
260
  <FilterChip label="Role" summary={roleSummary} onClear={() => setRoleSel([])} clearLabel="Clear role filter">
261
- <PickerMenu multi options={ROLES} value={roleSel} onValueChange={setRoleSel} />
261
+ <OptionList search={{ mode: "none" }} multi options={ROLES} value={roleSel} onValueChange={setRoleSel} />
262
262
  </FilterChip>
263
263
  <FilterChip label="Amount" summary={amtSummary} onClear={() => setAmt([AMT_MIN, AMT_MAX])} clearLabel="Clear amount filter">
264
264
  <RangeSlider
@@ -21,7 +21,7 @@ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
21
21
  import { SearchInput } from "@lotics/ui/search_input";
22
22
  import { ChipGroup } from "@lotics/ui/chip_group";
23
23
  import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
24
- import { PickerMenu } from "@lotics/ui/picker_menu";
24
+ import { OptionList } from "@lotics/ui/option_list";
25
25
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
26
26
  import { Combobox } from "@lotics/ui/combobox";
27
27
  import type { PickerOption } from "@lotics/ui/picker";
@@ -356,9 +356,9 @@ export function TplDossier() {
356
356
  onClear={() => { setAssignee([]); setPage(0); }}
357
357
  clearLabel="Clear assignee filter"
358
358
  >
359
- <PickerMenu
359
+ <OptionList
360
+ search={{ mode: "none" }}
360
361
  multi
361
- enableSelectAll
362
362
  options={ASSIGNEES}
363
363
  value={assignee}
364
364
  onValueChange={(v) => { setAssignee(v); setPage(0); }}
@@ -373,7 +373,8 @@ export function TplDossier() {
373
373
  clearLabel="Clear fee filter"
374
374
  >
375
375
  {({ close }) => (
376
- <PickerMenu
376
+ <OptionList
377
+ search={{ mode: "none" }}
377
378
  options={[{ value: "paid", label: "Paid" }, { value: "unpaid", label: "Unpaid" }]}
378
379
  value={feeStatus}
379
380
  onValueChange={(v) => { setFeeStatus(v); setPage(0); }}