@lotics/ui 11.3.0 → 11.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.
@@ -0,0 +1,439 @@
1
+ # Composition grammar
2
+
3
+ The design-language contract for composing `@lotics/ui` screens: how a page is laid out, which
4
+ heading construct belongs at which altitude, when content is carded vs flat, how rows/registers/
5
+ drawers connect, the view-control vocabulary, and the color/typography/whitespace laws. Read this
6
+ before laying out any screen — the component inventory lives in [the catalog](./catalog.md), and
7
+ full worked-example screens ship as source in `examples/tpl_*.tsx` (picked by job in
8
+ [templates](./templates.md)). Every rule here is enforced by the primitives' defaults; fighting
9
+ them produces off-system UI.
10
+
11
+ ## Canvas & content column
12
+
13
+ - **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28` (a `Drawer`'s scroll body
14
+ uses 24). Cards separate from the canvas by their own border + shadow, not by a tinted
15
+ background.
16
+ - **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
17
+ sized to the job, with `gap: 16` between top-level bands/cards:
18
+
19
+ | Job | typical `maxWidth` |
20
+ |---|---|
21
+ | Focused task flow (a pick run, a checklist) | 560 |
22
+ | Single-record form / feed | 680–720 |
23
+ | Dashboard, pivot, faceted browse | 920–1000 |
24
+ | Register / work list / calendar | 1040 |
25
+ | Wide wallboard / report | 1100 |
26
+
27
+ ## The heading ramp — one construct per altitude, no drift
28
+
29
+ **The ramp is FIXED — the markdown ladder, no size knobs.** One outline on every surface; never
30
+ restyle a heading level per-page.
31
+
32
+ | Rank | `Text` size | Construct |
33
+ |---|---|---|
34
+ | `#` | `xxl` semibold | Page title — `PageHeader` / `RecordSummary` title |
35
+ | `##` | `xl` semibold | Section title — `SectionHeadingTitle`, always |
36
+ | `###` | `lg` semibold | Subsection title — `SubsectionHeadingTitle`, always |
37
+ | body | `sm` | Everything else |
38
+
39
+ - **Page band** — one per screen: a `Text size="xxl" weight="semibold"` title (`#`) + one `sm`
40
+ muted subtitle that says what the screen decides. Right side: the screen's ONE primary action
41
+ and/or a period filter — never a summary `Badge` (those belong to the KPI strip).
42
+ - **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
43
+ `CardHeaderTitle` (`sm` semibold; pass `info` whenever the title alone doesn't define the
44
+ numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
45
+ - **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
46
+ (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
47
+ at the column edge; `SectionHeadingMeta` and actions ride the heading row's right edge (the
48
+ title's `flex: 1` pushes them). The page column is a **`SectionStack`** — it owns the
49
+ between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
50
+ null/false children are skipped so a conditional section never leaves a stray hairline). The
51
+ `Divider` NEVER goes directly under a heading — that orphans the title from its own content.
52
+ Inside a DRAWER, a full multi-section workspace uses the real `SectionHeading` (its meta + action
53
+ slots carry each section's count and CTA); a `Text size="sm" weight="semibold"` stand-in is only
54
+ for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT hand-roll
55
+ sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
56
+
57
+ **Warning:** two different `Section` components ship. The compound one described here comes from
58
+ `@lotics/ui/section_heading` (alongside `SectionHeading`/`SectionHeadingTitle`/
59
+ `SectionHeadingMeta`/`Subsection`/…). `@lotics/ui/section` exports a LEGACY collapsible
60
+ `Section` with a `title` string prop — a different component. Import from `section_heading` for
61
+ the grammar described here.
62
+ - **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
63
+ `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold; siblings — a `Badge`, a
64
+ `SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections stack
65
+ in a **`SubsectionStack`** (the same hairline law one step tighter: a fixed 24px beat + hairline
66
+ between groups — no margins, no hand-rolled dividers); a headingless `Subsection` is the
67
+ section's lead group. Group leads INSIDE a subsection's rows are `md` medium at most. Do NOT
68
+ hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
69
+ to its own section-level heading just to separate it.
70
+ - **Eyebrow / label** — `<Text size="xs" color="muted" weight="medium">`, **sentence case, NEVER
71
+ `transform="uppercase"`**: a small quiet label above or beside content — an artifact tag
72
+ ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
73
+ label. **All-caps is banned** — it reads as shouting, and reflexively uppercasing every little
74
+ label is the #1 thing that makes a surface feel templated. Sentence case + medium weight, full
75
+ stop. (A COLORED status word — a verdict like "Mismatch" / "Resolved" — keeps the same xs/medium
76
+ shape with a status `color`, still not uppercase.) This applies to every hand-written label;
77
+ don't reach for a wrapper component either — just write the `Text`.
78
+ - **Gate header** — a `Dialog` uses `DialogHeaderTitle`; a popover form uses
79
+ `Text size="sm" weight="semibold"` + an optional xs muted subtitle.
80
+
81
+ ## Period filters for time-constrained data
82
+
83
+ Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
84
+ badge. Every period-dependent number MUST follow the selection. Pass `includeTime` when the
85
+ time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
86
+ translates the presets, footer buttons, placeholder, and the time-segment editors.
87
+
88
+ ## Keyboard & focus
89
+
90
+ - **Use `tabIndex`, never `focusable`.** React Native Web's `Pressable` silently ignores
91
+ `focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
92
+ `tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
93
+ (`Tabs`/`SegmentedControl`/`RadioPicker`) keep ONE stop at `0`, the rest `-1`.
94
+ - **NEVER let a FOCUSED control unmount.** A conditional pointer affordance that vanishes on use
95
+ (e.g. an "apply suggested value" pill shown only while a field is empty) must be
96
+ `tabIndex={-1}`, or the browser drops focus to `<body>` and the next Tab jumps to the page's
97
+ first focusable.
98
+
99
+ ### Focus rings are per-component, never global
100
+
101
+ There is NO global focus CSS — the kit only resets the native outline. Every interactive control
102
+ paints its OWN ring; nothing rings unless it opts in. Three ways, pick by base:
103
+
104
+ - **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on
105
+ keyboard focus WITHOUT the hover wash; the base for any control whose hover affordance is its
106
+ BORDER, not a wash (inputs/selects, the inline editors, cells, tiles, nav buttons, menu
107
+ options). Forwards all `PressableProps`; its state-fn `style` exposes `hovered`/`focusVisible`
108
+ so the control paints its own border-hover. Reach for this before hand-rolling a Pressable + the
109
+ hook.
110
+ - **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible`
111
+ from its style-fn / children for a bespoke treatment, e.g. `CardSelectItem`, which rings on
112
+ hover/press/focus).
113
+ - **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom
114
+ surfaces. Returns `{ focusVisible, focused, focusProps }`; spread `focusProps`, apply
115
+ `focusVisible && { boxShadow: FOCUS_RING }` (comma-join any existing box-shadow).
116
+ `useFocusRing({ always: true })` for text-like inputs/selects (ring on ANY focus, as browsers do
117
+ for typing-capable fields); plain controls omit it (keyboard-only).
118
+
119
+ The ring is the shared **`FOCUS_RING`** token (`@lotics/ui/control_surface` — a 2px zinc-900
120
+ box-shadow flush against the box, layout-neutral). A mouse-opened popover trigger
121
+ (Select/Combobox/InlineSelect/InlineDatePicker) wears the SAME token on its open state, so an open
122
+ trigger reads identically to a keyboard-focused control. **Coverage is a contract**: EVERY
123
+ interactive control must ring — a focusable control with no focus treatment is a bug; a surface
124
+ that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
125
+
126
+ ## Hover grammar — intensify the control's own signature
127
+
128
+ Hover intensifies a control's OWN resting signature — never a foreign affordance. **Pick the base
129
+ by WHAT THE SURFACE IS; never a raw `Pressable`.**
130
+
131
+ | Surface | Examples | Resting signature | Hover | Base |
132
+ |---|---|---|---|---|
133
+ | Fields (type into / pick from) | `TextInputField`, `Picker`, `Select`, `Combobox`'s input, `NumberInput`, the date/time fields, `DateRangeFilterField`, the inline editors (`InlineSelect`/`InlineDatePicker`) | a BORDER | darkens the border to `HOVER_BORDER` (zinc-400); a borderless in-cell field REVEALS one | `FocusRingPressable` |
134
+ | Actions | `Button`, `IconButton` | a FILL | darkens the fill (own color logic) — never a border, never a wash | built-in |
135
+ | 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` |
136
+ | Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
137
+
138
+ For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
139
+ open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. The bug this
140
+ grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
141
+ greys *and* animates its edge while its siblings only border-hover. Reach for `PressableHighlight`
142
+ on a bordered/input control and you've made it.
143
+
144
+ ## Cards — banded and composable
145
+
146
+ All from `@lotics/ui/card`. A plain `<Card>` has a 20px padding and the author owns the content; a
147
+ **banded** card zeroes the padding and composes the family slots:
148
+
149
+ ```tsx
150
+ <Card style={{ padding: 0 }}>
151
+ <CardHeader>
152
+ <CardHeaderTitle info="what this shows">Title</CardHeaderTitle>
153
+ <CardHeaderMeta>12</CardHeaderMeta>
154
+ </CardHeader>
155
+ <CardBody>…</CardBody> {/* repeat with <Divider/> between */}
156
+ <CardFooter>hint(flex:1) + actions</CardFooter>
157
+ </Card>
158
+ ```
159
+
160
+ Never nest cards; never hand-compose a title or footer band. `info` is standard, not garnish — a
161
+ reader who doesn't already know the data shouldn't have to guess what a number means.
162
+
163
+ ### Flat work execution & data capture vs carded monitoring
164
+
165
+ A `Card` is ALWAYS a bordered, lifted container — **there is no "flat card"**. MONITORING /
166
+ dashboard screens group with cards (and the `KPIStrip` band). WORK-EXECUTION screens — registers,
167
+ lists, worklists — AND **data-capture / record screens (forms, wizards, billing, settings, inline
168
+ records) are FLAT**: content sits DIRECTLY on the white canvas with no Card wrapper; separation
169
+ comes from the row wash, gaps, and the `SectionHeading` + `Divider` rhythm. Reach for a Card only
170
+ where a screen holds a DISTINCT region that must be told apart from another; a single register or
171
+ list filling the screen needs none. ONE carve-out inside a flat form: **repeating SUB-OBJECTS
172
+ (line items) are each their own small Card** (`padding: 16`), so discrete items read as discrete
173
+ objects — hairlines between them blur into one run-on list.
174
+
175
+ ## Summaries — two altitudes, never mixed
176
+
177
+ | Altitude | Component | Where | Shape |
178
+ |---|---|---|---|
179
+ | Dashboard | `<KPIStrip items={[{label, value, trend?, caption?, tone?, compact?, info?}…]}>` | TOP of a dashboard | a boxed stat band — a `Card` of `KPICard` columns |
180
+ | Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?}…]}>` | BELOW the toolbar | a light inline line — a small `Metric` + muted label per item |
181
+
182
+ A `SummaryLine` is recomputed from the FILTERED rows so it always describes WHAT'S SHOWN. Both are
183
+ INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if there are no
184
+ cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
185
+ a dashboard. Card stat rails use `KPICard`.
186
+
187
+ ## Numbers, money, dates
188
+
189
+ - Free-standing numerals: `<Text tabular>` — always.
190
+ - **Money**: `formatMoney(n)` from `@lotics/ui/format_money` (`compact` abbreviates values
191
+ ≥ 1,000,000 for strip captions). **Warning:** the full-value path formats via `Intl`, whose
192
+ output separates the currency symbol with a non-breaking space (the compact path uses a plain
193
+ space) — normalize `\s` when asserting on its output in tests.
194
+ - **Dates**: `formatDate(value, opts)` from `@lotics/ui/format_date` — the date-VALUE formatter.
195
+ `format` (the date STYLE) ∈:
196
+
197
+ | `format` | Renders |
198
+ |---|---|
199
+ | `date` (default) | `22/05/2026` |
200
+ | `medium` | `22 thg 5, 2026` |
201
+ | `long` | `22 tháng 5, 2026` |
202
+ | `dayMonth` | `22 thg 5` |
203
+ | `monthYear` | `Tháng 5 2026` |
204
+
205
+ **`time: true` is orthogonal** — it prepends the 24h time to ANY style (`14:30 22/05/2026`,
206
+ `14:30 22 tháng 5, 2026`); `compact` drops the year on the `date` style; `emptyLabel` renders
207
+ for null/invalid input. It's `Intl` + the product's conventions (stable `/` separators,
208
+ timezone-naive ISO parsed as local wall-clock, 24h time-first) — never hand-roll a date via
209
+ `Intl`/`padStart` for value display. For an order the presets lack (e.g. time-first
210
+ `HH:mm dd/MM/yyyy` in a doc builder): `parseDate` + assemble the missing order — never a regex
211
+ re-implementation. (Exempt: a component's own internal chrome — a calendar's header/weekday/a11y
212
+ labels, a gantt axis — renders its own set.)
213
+
214
+ ## Every number is a door
215
+
216
+ Except the KPI strip: a component that summarizes records leads to the records behind it when
217
+ pressed — switch to the filtered list, expand in place, or navigate. Expansion happens IMMEDIATELY
218
+ below the pressed element — the composable `Accordion` family (`AccordionHeader` / `AccordionTitle`
219
+ / `AccordionMeta` / `AccordionContent`); a header-only accordion renders a plain row of identical
220
+ rhythm, so lists mix expandable + static rows. The body is **flush** with the header's left edge
221
+ (aligns with the title — like a `Section` body, no indent, no tinted well). `AccordionHeader` is a
222
+ layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
223
+ **collapsible Section** (configurable font/level/description + count) compose the SAME
224
+ `SectionHeadingTitle`/`SectionHeadingMeta` inside it — one heading family across static +
225
+ collapsible.
226
+
227
+ ## No dead rows — registers & tables
228
+
229
+ Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
230
+ (sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
231
+ read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
232
+ `ActionMenu` (⋯ → `MenuButton` items: destructive last + **confirmed** via `Alert`). The whole
233
+ surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
234
+ Divider-separated.
235
+
236
+ | `PressableRow` variant | Shape | Use |
237
+ |---|---|---|
238
+ | `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash; content sits on the 20px gutter so a `Table` header + cells align | THE record list |
239
+ | `bleed` (legacy) | 20px square, `Divider`-separated | only an edge-to-edge data grid that genuinely wants hard rules |
240
+ | `inset` | rounded (radius 8), pulled in from the edge | grouped lists in a padded container (Accordion drill-downs) |
241
+
242
+ The `Divider` BETWEEN rows is the resting separation; the wash is the hover state. `selected`
243
+ shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
244
+
245
+ **The COLUMNAR register is `Table`/`TableRow`/`TableCell`**: define `columns` once — the header
246
+ band + every row's cell widths derive from it and can't drift. ONE hairline under the header; the
247
+ rows below it Divider-separated. A sortable column shows its `SortHeader` glyph ALWAYS (a neutral
248
+ up/down glyph when unsorted) so it reads as sortable; pressing cycles none → asc → desc → none.
249
+ A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash, no pointer cursor) —
250
+ so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
251
+ registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
252
+ an empty overlay with no content to derive a name from.
253
+
254
+ Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
255
+ `CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
256
+ rows `marked`, paired with a `FloatingActionBar`. The selection state is the **`useSelection()`**
257
+ hook (`@lotics/ui/use_selection`: `selected` / `has` / `count` / `toggle` / `setAll` /
258
+ `allSelected` / `indeterminate` / `clear`) — gating which rows CAN be ticked stays with you: pass
259
+ only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selectable row gets a
260
+ disabled checkbox, keeping the gutter aligned). Selection persists across pages, so `count` is the
261
+ running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination` in the
262
+ `CardFooter`).
263
+
264
+ Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
265
+ a fixed `width`, so amount/status columns don't jitter.
266
+
267
+ ## Row actions are always-visible siblings — never hover-revealed, never nested
268
+
269
+ A pressable row = a role-less surface (the hover/press wash covers the whole row) + an accessible
270
+ "Open …" door (a button) + trailing actions (⋯, remove ✕, edit) as SIBLINGS of the door. A button
271
+ must not contain another button, so each action is independently pressable and the ✕ isn't
272
+ swallowed by the row press (`FileRow` and `PressableRow` are the references). The door wraps the
273
+ row body ONLY when that body is non-interactive by construction (`FileRow`'s name/meta); where the
274
+ body is arbitrary app content — `TableRow`'s cells, which legitimately carry a `Link` or a popover
275
+ trigger — the door is an EMPTY absolutely-positioned sibling under the cells (tab stop + name +
276
+ focus ring; the mouse rides the surface), so a control in a cell never nests inside it.
277
+
278
+ Actions stay **visible** — a hover-only action is invisible to keyboard and touch users. The ONLY
279
+ exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
280
+ hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its opacity,
281
+ so it's still keyboard-reachable. Do that locally — there is deliberately no shared hover-reveal
282
+ primitive (one existed and made the keyboard-inaccessible anti-pattern easy; it was removed).
283
+
284
+ ## Master-detail = list + workspace Drawer with sequencing
285
+
286
+ Pressing a PRIMARY entity row opens the record workspace in a `Drawer` with `onPrev`/`onNext`/
287
+ `position` ("3/24") over the visible ordering (←/→ arrow keys are built in). Key the drawer body by
288
+ record id so per-record state resets on step. Facts are `DetailRow`s; the commit bar is a
289
+ `DrawerFooter` (a hairline-topped band pinned to the panel bottom — render it as the LAST child,
290
+ after the scroll body; actions sit right, a leading `<Text style={{ flex: 1 }}>` hint pushes them
291
+ there). The open row shows a `selected` highlight (and `marked` for a bulk-ticked row). `Peek` is
292
+ ONLY for secondary references — a glanceable summary popover on an inline reference (a customer
293
+ name, a record id) with ONE action to the full record — never the primary row press.
294
+
295
+ ## Two work shapes, no side panels
296
+
297
+ A work screen is ONE of two shapes — never a persistent side-by-side split (it dies on a phone,
298
+ and the "detail" of a list belongs in a `Drawer`/sheet, not a second column):
299
+
300
+ 1. **Work each** — a flat register/list; press a row → its workspace `Drawer` (above).
301
+ 2. **Act on many** — a flat list with a leading `CheckboxInput` per row + a select-all header
302
+ band, plus a bottom-pinned `<FloatingActionBar count label onClear>` carrying the bulk
303
+ action(s) while ≥ 1 row is ticked (it renders nothing at count 0); a row that can't take the
304
+ action has no usable checkbox. Bar weight gradient, left → right: the built-in Clear is muted
305
+ (the quietest act), destructive bulk = `danger-secondary` + icon, secondary bulk = `secondary`
306
+ + icon, ONE primary CTA last — never repeat the count in a CTA title; the bar's label already
307
+ carries it.
308
+
309
+ When a region genuinely must sit beside the list (a source/remainder summary, a capacity picker),
310
+ make it a TOP summary header or move it into a `Popover`/sheet at the decision point — not a
311
+ standing column.
312
+
313
+ ## One control radius
314
+
315
+ Every interactive control (`Button`, the inputs/selects/pickers, `SearchInput`, `MenuButton`,
316
+ `Tabs`, the `SegmentedControl` track, `ChipGroup`/`FilterChip`/`Chip`) wears **`CONTROL_RADIUS`**
317
+ (10, from `@lotics/ui/control_surface`) — never a literal. Containers are deliberately larger
318
+ (`Card` 16). An element NESTED inside a padded control (a segmented thumb, a
319
+ combobox chip, an ✕ on a chip) takes `CONTROL_RADIUS − padding` so its corner stays CONCENTRIC
320
+ with the parent — set it equal and the inner corner bulges past the outer curve. Round-by-function
321
+ controls (avatars, status dots, switches, sliders, progress bars, `IconButton`) stay full (999); a
322
+ small square toggle (`Checkbox`, 24px) keeps a proportional small radius (6 — the same fraction of
323
+ its box that 10 is of a 40px control), not the band radius. No capsules — `Button` has no pill
324
+ shape.
325
+
326
+ ## One view-control vocabulary
327
+
328
+ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping band:
329
+
330
+ - **`SearchInput`** is THE search box — a white `TextInputField` preset (the shared
331
+ `CONTROL_RADIUS` like every other input, a thin border, the leading search glyph + a clear ✕).
332
+ It reads as search by its ICON + white fill, not a distinct shape, so a toolbar of controls
333
+ stays one consistent band. Never a bare `TextInputField` + search icon.
334
+ - **`ChipGroup`** is THE one-of-N lens (≤ ~10 options the user flips between; include "All";
335
+ counts in the label). Filters ONE list to a SUBSET — including by process stage / lifecycle
336
+ state.
337
+ - **`FilterChip`** is THE secondary-dimension filter — a compact chip that opens a composed
338
+ popover editor (`OptionList` multi / `RangeSlider` / `Counter` / date range). Single-select
339
+ closes on pick via the `{({ close }) => …}` render prop.
340
+ - **`ColumnFilter`** is the TYPED column filter — give it a `FilterableColumn`
341
+ (`type: "text" | "number" | "select"`) + a controlled `ColumnFilterValue` and it renders the
342
+ right editor (contains / range / multi-select) inside a `FilterChip`, with
343
+ `columnFilterToConditions` mapping the value to query predicates. Reach for it — ONE compact
344
+ chip per dimension — when a register filters on SEVERAL columns (origin, destination, carrier,
345
+ type…). Never hand-roll a row of bespoke chips/filters, and never use a permanently-expanded
346
+ `ChipGroup` for a secondary dimension.
347
+ - **`Combobox`** is the SEARCHABLE picker — reach for it over `ColumnFilter` when the value is ONE
348
+ of a large/growing set you type to narrow (a port, a customer) rather than a short fixed list.
349
+ Pass `recentOptions` (the same option list) so a click opens the full list, then typing filters.
350
+ The **From → To route picker** is two of them (`icon="map-pin"`, `clearable`) with an
351
+ `arrow-right` between — flight-search style.
352
+ - **`Tabs`** switch between VIEWS/sections (different content/layout) — never a subset of one
353
+ list. A tab earns a `status` dot (the optional `TabOption.status`, a `ColorName`) only when its
354
+ area needs attention (a blocker / missing item) — the resting state has none.
355
+ **`SegmentedControl`** chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
356
+ - **Layout**: search + secondary filters LEFT, the primary CTA RIGHT, in ONE band. A `ChipGroup`
357
+ one-of-N lens is fine ONLY when the band has no `SearchInput`; once a search is present, the
358
+ status filter becomes a `Select`/`FilterChip` dropdown — never a row of pills competing with the
359
+ search.
360
+
361
+ ## Commit & feedback surfaces
362
+
363
+ - **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter` default `align="end"`;
364
+ `DrawerFooter` is right-aligned by construction. Commit at the right edge, secondary to its
365
+ left. Never left-flow a Save/Submit.
366
+ - **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
367
+ - **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
368
+ Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
369
+ `Alert` is the blocking modal; `Badge` is a one-word pill.
370
+ - **One primary action per surface.** Everything else secondary/muted; destructive = `danger`
371
+ styling + an explicit label. (A grouped BUILDER section — line items — MAY give its own Add
372
+ action `primary`; it sits at a different altitude than the form's one terminal commit.)
373
+ - **Button labels carry no trailing ellipsis** ("Assign", not "Assign…"). **Button color is
374
+ VALENCE/RISK, never category**: the ladder `muted < secondary < primary` is the emphasis axis;
375
+ `danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis. No
376
+ "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
377
+ (colored `Text`), not the buttons.
378
+
379
+ ## Color discipline — solid / tint / ramp + ONE accent
380
+
381
+ Every status / data-viz / accent color comes from a NAMED helper (`@lotics/ui/colors`), never a
382
+ hand-picked `colors.<family>[shade]`:
383
+
384
+ | Helper | Returns | Use |
385
+ |---|---|---|
386
+ | `solid(name)` | the family's 500 | dots, chart series, meter accents |
387
+ | `tint(name, α)` | a low-alpha wash of the 500 | cell/segment tints, dimmed states |
388
+ | `ramp(name, count)` | N shades of ONE family, strong → light | a coherent single dimension (an ordered funnel, a breakdown) — the label carries identity, the shade only orders |
389
+
390
+ A status is ONE family NAME (`{ color: "emerald" }`) and every weight derives from it. **One
391
+ accent per screen purpose** (blue = pipeline, emerald = money, red = danger, amber = waiting).
392
+ Direct `colors.*` access is reserved for NEUTRALS (`zinc`, `border`, `white`, the `*[50]`
393
+ selection washes).
394
+
395
+ **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
396
+ `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
397
+ (emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
398
+ (rose vs orange) stays on `Badge`s. Links use `Link`/`TextLink` (blue-600), never `solid("blue")`.
399
+
400
+ ## Status indicators have a WEIGHT — match to prominence, never to a metric
401
+
402
+ Lightest → heaviest:
403
+
404
+ 1. **Colored `Text`** — a verdict WORD in a header (the valence tokens above).
405
+ 2. **`Badge variant="dot"`** — a categorical STATE in a scannable column / legend.
406
+ 3. **`Badge`** (tonal pill, the default) — a record's PROMINENT status: a drawer/detail header.
407
+
408
+ A register's dense rows read lighter — the row's primary status is `variant="dot"`, its drawer
409
+ twin tonal. A `Badge` is never a metric value.
410
+
411
+ ## Typography
412
+
413
+ Only the `Text` primitive (`size` / `weight` / `color` / `transform` / `tabular` / `level` for
414
+ semantic heading rank). Uppercase tracking is built into `transform="uppercase"` — never hand-set
415
+ `letterSpacing`.
416
+
417
+ On web, the heading sizes are **responsive** at the 768px viewport breakpoint (the kit's screen-size
418
+ boundary); native uses the base column:
419
+
420
+ | `size` | base / native | web ≥ 768px | Role |
421
+ |---|---|---|---|
422
+ | `xs` | 12 | 12 | eyebrows, meta, captions |
423
+ | `sm` | 14 | 14 | body (the default) |
424
+ | `md` | 16 | 16 | emphasized body, group leads |
425
+ | `lg` | 18 | 20 | `###` subsection titles |
426
+ | `xl` | 22 | 24 | `##` section titles |
427
+ | `xxl` | 28 | 32 | `#` page/record title |
428
+ | `xxxl` | 32 | 48 | hero numbers |
429
+
430
+ (Line heights are baked per size and platform-tuned; don't override them.) Weights: `regular` / `medium` /
431
+ `semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
432
+ (`danger`/`warning`/`success`), and the explicit `zinc-900/700/500/400` steps (`zinc-400` is the
433
+ disabled ink).
434
+
435
+ ## Touch & whitespace
436
+
437
+ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px between cards, 24–28
438
+ canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
439
+ hierarchy, not cramming.