@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.
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- # @lotics/ui — the UI reference
1
+ # @lotics/ui — the UI reference index
2
2
 
3
3
  > **Upgrading to v11 from ≤10.x — the type scale was re-slotted.** The display sizes gained the
4
4
  > missing `##` step: `xl` is now 22/24 (section title), `xxl` is 28/32 (page/record title — the
@@ -8,1102 +8,43 @@
8
8
  > ALWAYS `##` (22/24), and disabled `Button`s fade their label to the zinc-400 disabled ink on
9
9
  > every variant.
10
10
 
11
- A React-Native-Web component kit (renders on web **and** native). This file is the design
12
- law + the "reach for what" guide. The **exact API** of any component is its source, which
13
- ships with the package: read `node_modules/@lotics/ui/src/<name>.tsx`. **Never guess a prop
14
- open the file.** If the closest component lacks a capability, **extend it** (a platform change
15
- every app inherits), never inline a one-off `View`/`Text` rebuild — that forfeits the
16
- typeahead, async search, virtualization, and a11y the primitive already ships.
17
-
18
- - Import per module: `import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox"`.
19
- - Floating content (`Dialog` · `Popover` · `Tooltip` · `Alert` · `OptionList`) needs a
20
- `PortalHost` at the app root.
21
- - RN-Web only: `View`/`ScrollView` from `react-native`, the `Text` primitive (no raw
22
- `div`/`span`, no raw `fontSize`/`fontWeight`); styles are RN objects.
23
- - Fonts: the app entry imports the Inter sheet ONCE — `import "@lotics/ui/fonts.css"` (400/500/600,
24
- served by absolute URL so it resolves on every origin an app runs from) or every `Text` falls
25
- back to system fonts. File picking: `pickFiles` (`@lotics/ui/file_picker`) opens the browser
26
- picker and resolves the chosen `File[]` the imperative half behind every Add-file CTA.
27
- - i18n: most components are string-free. For the rest, set the language ONCE at the root with
28
- **`LoticsLocaleProvider`** (`@lotics/ui/locale`, the sibling of `LoticsThemeProvider`)wrap
29
- `<LoticsLocaleProvider locale={vi}>` and every wired component picks up the pack, no per-instance
30
- props. Shipped packs: `en` (default) and `vi` (one canonical Vietnamese translation, so apps don't
31
- redefine — or drift on — strings). Resolution is **prop → provider locale → English default**, so a
32
- per-instance `labels`/`clearLabel`/`selectAllLabel` still overrides for one-offs, and an un-wrapped
33
- app stays English. Wired so far: `Pagination`, `SortHeader`/`Table.sortLabels`, `OptionList`
34
- (+ `Select`/`Combobox` built on it), `FilterChip`, `Drawer`, `Confidence`, `RemainderMeter`,
35
- `ChangeReview`/`ChangeReviewActions`, `DateRangeFilterField`/`DateFilter` (presets + footer +
36
- time-segment a11y, one `dateRange` slice), `FormField` (the `optional` marker), and
37
- `FileRows`/`FileGalleryModal`/`FilePreview` (one `gallery` slice: the menu incl. remove+confirm,
38
- the gallery chrome, and the preview captions). Adding a string to a wired component's `*Labels` forces
39
- both packs in `locale.tsx` to fill it (compile error) that's how the kit avoids a silent English
40
- leak. Not yet on the provider (pass `labels` for now): calendar/gantt, the comment labels.
41
-
42
- ---
43
-
44
- ## Reach by role — what exists
45
-
46
- Pick by capability, not by name. (→ the source file for the API.)
47
-
48
- - **Actions** `Button` (labelled; `title` is MANDATORY; `color` = emphasis/risk),
49
- `IconButton` (icon-ONLY the circular affordance; an icon-only Button is a type error, so reach
50
- here. `size` `lg` 40px / `md` 28px / `sm` 24px, the SAME `color` palette as Button, plus `loading`
51
- and `elevated` (white fill + border + shadow, for a button sitting ON imagery — a tile's remove ✕,
52
- an overlay's retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's
53
- OPTIONALLY an action (`onPress`) or a link (`href`) — or, with neither, plain underlined text you
54
- drop in your own pressable like a table cell; colour via `color`; the go-to for Clear / Select all /
55
- inline links), `Chip` (dismissible facet chip). A button is never a raw `Pressable`. For a link OUT
56
- (a URL / record / document) use `Link` — fixed underline+blue + `role="link"`, the destination
57
- signal; `TextLink` is the neutral, colour-configurable underlined link/action.
58
- - **Pick from a list** — `Picker` (native `<select>`, plain label-only single, native typeahead)
59
- or `Select` (the rich one — custom-rendered options, single/multi, select-all). Search-as-you-type
60
- / async / create-new → `Combobox`. A selectable card row → `CardSelectItem`. For a SELECT-FIELD
61
- picker, render each option as its colored chip on `Select`
62
- (`renderOptionContent={(o) => <OptionBadge value={o} />}`). `Select` opens the shared
63
- `OptionList` body; `Combobox` is COMPOUND (`ComboboxInput` + `ComboboxContent`) over the same
64
- `useOptionList` engine; `Picker` is the only one that's a native dropdown.
65
- - **Pick member(s)** — `MemberSelect` (a `Select` that renders each option as a `MemberChip`,
66
- single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
67
- Don't re-wire `Select` + `renderOptionContent` + a directory by hand. For an assignee FILTER that
68
- also matches unassigned records, pass `unassignedLabel` → it prepends a muted "unassigned" option
69
- (value `MEMBER_UNASSIGNED`), so you never hand-roll a mixed member+none `Select`. To edit a `select_member`
70
- field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
71
- - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
72
- ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
73
- resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
74
- `Avatar` + `Text`. (The product's `MemberBadge` is just `memberId → MemberChip`; `MemberSelect`
75
- renders these per option.)
76
- - **A select-field value** — `OptionBadge` (a stored `select` value as its CONFIGURED colored
77
- badge) — never hand-map option-key → color. Feed it a resolved option (`useFieldOptions` for a
78
- picker option, or `byKey(readSelect(cell)[0]?.key)` for a stored value); multi-select wraps to
79
- one badge each; a missing/unknown color token degrades to neutral.
80
- - **Tags / multi-value chip box** — `Select multi` whose `renderSelected` returns a removable
81
- `<Chip onDismiss={remove}>` (with `searchable` + `allowCustom`). The chip box is COMPOSED, not a
82
- separate control — there's no `display` mode; `renderSelected` is the seam (see §Data entry).
83
- - **Text & form** — `TextInputField`, `NumberInput`, `SearchInput`; wrap with `FormField`;
84
- `Checkbox`, `Switch`, `RadioPicker`; dates via `DatePicker` / `DateRangeFilterField`, times
85
- via `TimePicker`.
86
- - **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
87
- `InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
88
- `InlineTimePicker`; a READ-ONLY field in that same column uses `InlineStatic` (matches the
89
- editor box exactly, no input chrome, so it aligns pixel-for-pixel) (see §Data entry).
90
- - **Tasks / to-dos — pick by ALTITUDE.** A task-management PAGE (many tasks, grouping, filters,
91
- expandable rows) is COMPOSITION — there is NO Task component (a Reminders row and a Linear
92
- column-grid share almost nothing): `CheckCircle` + a struck `InlineTextInput` + your meta cells;
93
- see `tpl_tasks` (quick list) and `tpl_task_board` (columns). A record's own small CHECKLIST
94
- (the 5–8 tasks living in a drawer/section) is the `Checklist`/`ChecklistRow` compound +
95
- `SuggestionChip` commons + `CaptureRow` — see `tpl_record`/`tpl_item_list`.
96
- - **Tabular data — pick by SCALE + intent.** Two columnar shapes, and the choice is about data size:
97
- - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
98
- headers via `SortHeader`; paired with `Pagination`) + read-only rows that open a `Drawer` to edit.
99
- It scales by PAGING — renders one page, never the whole set — so you FILTER + search, you don't
100
- group. Worked example: `tpl_item_list`. Never an HTML `<table>` or a `.map` of rows.
101
- - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view) — the
102
- `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors (ANY field — a
103
- column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`, so `InlineMemberSelect` /
104
- `InlineDatePicker` / `InlineNumberInput` / `InlineSelect` / a borderless `Select multi` for tags / colour-dot `OptionBadge`).
105
- `DataGrid` owns the sortable header + collapsible grouped sections + aligned rows + an optional
106
- per-row `leading` slot (a `CheckCircle`); YOU own the data, the sort/group/filter/collapse STATE
107
- (`cycleSort` + `sortBy`, a `collapsed` Set), the toolbar (`SearchInput` + `FilterChip`s), and the
108
- per-group add row (`renderGroupFooter`, aligned with the exported `gridRowStyle` + the column
109
- widths). It renders ALL rows (no virtualization), so it's only for sets small enough to hold in
110
- view — at 10k+ it lags, and grouping + pagination/infinite don't compose; use the register instead.
111
- Worked example: `tpl_task_board` (a `CheckCircle` leading).
112
- - **Numbers & charts** — `KPIStrip` (the dashboard stat band) · `SummaryLine` (the light inline
113
- register/list summary — below the toolbar, from the filtered rows) · `KPICard` / `Metric` (headline figures), `TrendChip`
114
- (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG set — no
115
- recharts), `RingGauge`, `ProgressBar` (its `compact` prop = ONE row, track + a plain sm
116
- tabular count beside it — the cell/heading/peek-trigger meter; a caption floating above a
117
- tiny bar reads misaligned) / `StackedProgressBar` / `StepProgress`, `Breakdown`
118
- (a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
119
- "Show N more" toggle — `labels` to localize — so several facet cards align to one height in a row),
120
- `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step conversion rate is the
121
- HEADLINE (a bold aligned row across the top, the first stage = the 100% baseline), the count is the
122
- supporting figure below — the Amplitude/Mixpanel convention, never pin the rate to the fill height.
123
- `orientation` vertical columns | horizontal bars; pass `onSelect`+`selectedKey` to make the bars
124
- press-to-drill (the selected stays solid, others dim — the caller renders the records). The
125
- subset/drop-off sibling of `StackedProgressBar` — nested cohorts that shrink "calls → connected →
126
- won", NOT a whole split across stages — that's `StackedProgressBar`),
127
- `StatusGrid` + `StatusLegend`, `Heatmap` (density: colour-only, "where does it cluster"),
128
- `Matrix` (the PIVOT cross-tab: the NUMBER in each cell — optionally a heat wash behind it — plus
129
- row/column/grand totals; press a cell to drill).
130
- - **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
131
- `CardFooter`), `Section` (the card-less twin of `Card`: bare gap-spaced region + `SectionHeading`/
132
- `SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body;
133
- ), `Subsection` (+ `SubsectionHeading`/
134
- `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
135
- `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
136
- top-level blocks), `SubsectionStack` (the same law one step tighter — fixed 24px beat +
137
- hairline between a section's `Subsection` groups), `SectionCard`,
138
- `PageHeader` /
139
- `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`,
140
- `DangerZone` (the destructive section — delete/archive — set apart at the bottom of a record/settings surface).
141
- - **Rows & registers** — `PressableRow` (THE register row; forwards its `ref`, so wrapping it in a `PopoverTrigger` anchors a row-triggered peek Popover — without the ref the trigger is unmeasurable and the popover renders off-screen), `ListItem`, `MenuButton`,
142
- `MenuListItem`, `DetailRow` (label+value for drawers/peeks; optional `trailing` slot for a
143
- right-side action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
144
- - **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
145
- `Counter`), `Chip`.
146
- - **Overlays** — `Dialog` (centered card over a scrim), `Modal` (+ `ModalHeader`/`ModalBody`/
147
- `ModalFooter` — a full-bleed, edge-to-edge takeover with NO scrim), `Drawer` (+ `DrawerFooter`),
148
- `Popover`, `Tooltip`, `OptionList` (the searchable list body — host it in a `Popover`/`Dialog` for
149
- a command palette), `Alert` (the blocking confirm).
150
- - **Status / feedback** — `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`,
151
- `CompletionState`, `ActivityIndicator` / `Loading`, `Skeleton`.
152
- - **Files** — `FilesEditor` (THE all-in-one attachment field: an upload-aware grid + a toolbar below
153
- it that swaps into a batch SELECT mode, full-screen preview, download/share, and Alert-confirmed
154
- remove — the host only owns `files` + wires `onAdd`/`onRemove`; mirrors the frontend
155
- `cell_files_editor`. Reach for this first for "manage a record's attachments"), `FileDropzone`,
156
- `FileRows` (batteries-included file LIST: tap a row → built-in
157
- full-screen gallery, with a per-row trailing ⋯ menu = Download · Open-external · Remove; the default "here are some files"
158
- surface), `FileGrid` (the upload-aware grid: completed files + a live upload
159
- queue in one surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own
160
- the chrome), `FileThumbnail` / `FileThumbnailGrid`
161
- (square tiles, display-only), `UploadingThumbnail` (the single in-flight tile FileGrid renders —
162
- reach for it only when hand-rolling a non-grid upload layout), `FileRow` (a horizontal file/document
163
- LINE — badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge / action
164
- / remove; `onPress` makes the whole row a pressable door, `trailing` stays an independently-pressable
165
- sibling; for checklists & readable lists), `FileBadge` (the two-tone type mark), `FilePreview` /
166
- `FileGalleryModal`, `ImageGallery`; for gated CRUD compose locally with `useSelectionMode` +
167
- `shareOrDownloadFiles` + `rotateImageToBlob` (see §Data entry → Attachments).
168
- - **Specialized work surfaces** — `ScanField` (scan/verify), `Stepper` (a guided run / progress
169
- sequence — done · current · upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow`
170
- (allocation), `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
171
- `Calendar`, `Gantt`, `comments_thread`.
172
- - **AI surfaces** — `Composer` (the adaptive command/chat composer — a compact pill when empty that
173
- expands for long text + attachments; the surface that triggers agent work),
174
- `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
175
- form — a composer's "working" state) + `Confidence`;
176
- **`ChangeReview` — THE one review-before-apply surface, a COMPOUND family** (frame: `ChangeReview`
177
- · `ChangeReviewHeader` · `ChangeReviewActions`; sections: `Change` · `ChangeLabel` ·
178
- `ChangeSummary` · `ChangeReasoning`; the grammar: `ChangeFields` + `ChangeField` ·
179
- `ChangeRecord` · `ChangeBand` + `ChangeValueInput`): adds, updates, removals, conflicts, whole
180
- records, display-only findings are all compositions — see §AI workflows for the laws;
181
- `Clarify` (the agent asks back — selectable `ChoiceList` options),
182
- `Sources` (provenance chips for AI output — at review scale, `label={null}` slots the chips at a
183
- section's bottom), `Finding` (one ranked insight from an AI check — localized severity word ·
184
- title · detail · `Sources` chips · a `children` slot; **`FindingComparison`** is the
185
- expected-vs-actual body: each disagreeing side a labeled row, the DELTA emphasized under a
186
- hairline (localized "Difference") — quantities, totals, dates; a plain `metric` prop remains for
187
- one-number findings. The children slot composes ANY visual result — a compact `Table` for
188
- per-line detail (danger color on the offending cells), `ProgressBar` for
189
- consumption-toward-a-cliff (free time, credit), dot `Badge`s for a present/missing checklist,
190
- `Confidence` for judgment calls — the AI-page demos show each. Display-only — it informs the verdict the host records; `finding` slice).
191
- See §AI workflows.
192
-
193
- ---
194
-
195
- ## Data entry — which pattern for which job
196
-
197
- This is the most common thing to get right. Match the JOB to the pattern:
198
-
199
- | You're capturing… | Reach for | Why |
200
- |---|---|---|
201
- | an EXISTING record's fields | **Inline edit** (`Inline*`) | edit in place, no form mode |
202
- | a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
203
- | a RELATED record (pick or make) | **find-or-create** (`Combobox allowCustom`) | one control covers both |
204
- | REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
205
- | CHARGES that bill onto documents | **billing** (`tpl_record` Billing section) | the invoice document is the unit |
206
- | a record's FEE/charge SUMMARY | **`Ledger`** (worked example: `tpl_item_list` drawer) | LedgerGroup (label + sum) → LedgerRow (label · meta · ONE right tabular money column; `peek` makes the row a door to its PARTICULARS in an anchored popover — references live inside the peek; `reference` = a trailing link on a non-peek row) → LedgerTotal (divider-set emphasized close, `zeroLabel` for the settled state). Pair with a Record-payment POPOVER that appends a receipt. No bars/charts |
207
- | a multi-value TAG field | **`Select multi`** (`renderSelected` → `Chip`) | chips composed, not a separate control |
208
- | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
209
- | a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
210
- | FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
211
- | a state TRANSITION mid-flow | **stage gate** (popover / dialog by weight) | right-sized friction |
212
-
213
- ### Inline edit — the preferred way to edit an existing record
214
- When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
215
- form mode or a preview↔edit card — make each VALUE inline-editable: it reads as plain text,
216
- hover reveals its border (it's a field — no grey tint, no pencil that shifts), click swaps the input in **at the same height** (zero
217
- reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
218
- `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
219
- (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
220
- `OptionList` in a popover so the row never grows; the RESTING value renders like its option
221
- (`renderOptionContent` by default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
222
- member picker; the inline twin of `MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact) · `InlineDatePicker` (`format="datetime"` for
223
- always-on time; `optionalTime` to let the user ADD/REMOVE a time — the value's own shape, date vs
224
- datetime, is the source of truth) · `InlineTimePicker` · `InlineTagSelect` (the MULTI member —
225
- a tag SET in the inline vocabulary: selected tags render as badges inside the standard chip,
226
- clicking floats a multi `OptionList` (checkbox rows), CLOSING commits the new set in one
227
- `onSave` — never a borderless `Select` posing as an inline field) — all
228
- on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
229
- saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
230
- without losing the edit. To let a value be UNSET (a diff-write CLEAR — unassign, remove a due
231
- date, drop a select), pass **`onClear`** to `InlineSelect` / `InlineMemberSelect` /
232
- `InlineDatePicker`. It surfaces through each popover's OWN clear affordance — `InlineSelect` renders
233
- a compact left-aligned "Clear" `Button` below the `OptionList` (only while a value is set);
234
- `InlineDatePicker` reuses the calendar's own footer "Clear" button. NOT a bolted-on sibling row: that
235
- can't reach the option list's active-highlight (so the last-hovered option stays lit) and would
236
- double the footer hairline;
237
- and never a persistent ✕ on the resting cell (noise on a dense board, and a pressable nested in the
238
- view's button trigger is invalid DOM). `onSave`'s `next` stays non-null so a caller opts in per field;
239
- the clear fires `onClear`, which writes `null` (the app workflow must ACCEPT null on that input — a
240
- `select`/`date`/`member` field clears on null). `InlineTagSelect` (multi) needs no `onClear` — an
241
- empty set is already a valid `onSave`. A STACK of rows lives in a `DetailTable` (label ·
242
- value · trailing laid out like a TABLE: `labelWidth` / `trailingWidth` /
243
- `minHeight` set ONCE on the parent, plus the 6px row gap the zinc-50 chips
244
- need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a
245
- trailing action/badge, so EVERY row reserves the column and one row's
246
- `Copy` button never makes its editor narrower than its neighbours'. An editor AT REST sits
247
- on a zinc-50 chip — THE editability affordance: users see what's editable without hovering; the
248
- picker-opening editors (`InlineSelect`/`InlineDatePicker`/`InlineTimePicker`) additionally carry a
249
- rest glyph (chevron / calendar / clock) marking them tappable pickers without hover — the
250
- touch-critical cue plain text/number fields don't get.
251
- `background="transparent"` opts a field out of the chip — for DENSE, uniformly-editable
252
- surfaces (a task list/board where EVERY cell edits: the chip repeated everywhere is noise and
253
- distinguishes nothing; hover/focus still reveal the input). Keep the chip wherever editable and
254
- static values MIX. A `disabled` editor rests FLAT automatically — the chip is the editability
255
- promise, and an inert field must not make it. A
256
- READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
257
- `InlineStatic`: it copies the editor box metrics exactly (height, padding, 1px transparent border)
258
- but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a glance
259
- and the static value never reads as a disabled input. To hang a right-side
260
- action/badge off a row, use `DetailRow`'s `trailing` slot (NOT for units — "kg"/"₫" belong IN the value via `InlineNumberInput format`) — see the "Details"
261
- `DetailTable` of `tpl_record`, which also reads top→bottom as a full record surface
262
- (header → fields → `DangerZone`). Not every field is a same-height swap — a tag field, a status, or an attachment grid
263
- edit in place too (below).
264
-
265
- ### Fieldset form — fields lay out on a RESPONSIVE two-column grid
266
- Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
267
- columnGap:16`; each `FormField` declares an intrinsic width — `half` (`flexGrow:1,
268
- flexBasis:240`) or `full` (`flexGrow:1, flexBasis:"100%"`) — so two halves sit side-by-side on a
269
- wide card and stack on a narrow one with zero media queries. Pair short, related fields as halves
270
- (phone/email, qty/price); give long or singular values the full row (legal name, address, notes).
271
- Past two columns the label→field link breaks — MANY inputs means GROUPING into labeled fieldsets
272
- (each its own 2-up grid, `Divider` between), never a third column. `FormDatePicker`/`FormPicker`
273
- wrap their OWN `FormField` (and omit `style`) — for a grid cell use a bare `FormField style={half}`
274
- wrapping `DatePicker`/`Picker`.
275
-
276
- ### Find-or-create — the `Combobox` family IS the control
277
- `Combobox` is COMPOUND: a root holds the DATA + behaviour (the shared `useOptionList` engine —
278
- `options`, `value`, `onValueChange`, `onSearchChange`, `allowCustom`, the per-row
279
- `getOptionDescription`/`renderOptionContent` so they're typed against the option `data`), and the
280
- parts render the CHROME, composed as children — never a render-prop pile:
281
-
282
- ```tsx
283
- <Combobox options={hits} value={sel} onSearchChange={setQ} onValueChange={pick} allowCustom
284
- customOptionLabel={(q) => `Create "${q}"`} getOptionDescription={(o) => o.data?.code}>
285
- <ComboboxInput icon="search" clearable onClear={deselect} placeholder="Find or create…" />
286
- <ComboboxContent recentsLabel="Recent">
287
- <ComboboxEmpty>No match — type a name to create one</ComboboxEmpty>
288
- <ComboboxFooter>{validity}</ComboboxFooter>
289
- </ComboboxContent>
290
- </Combobox>
291
- ```
292
-
293
- It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
294
- search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
295
- large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
296
- the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
297
- pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
298
- set (on focus an empty field already browses everything — `recentOptions ?? all`). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
299
- matches no option, emitting the typed text as the value — so a value not in the known set means
300
- CREATE. Wire that branch to a create overlay (an anchored popover for a 1–2 field gate, a modal
301
- `Dialog` for 3+) that builds the new record and attaches it; existing matches attach directly.
302
- A record-CREATION dialog stays a MINIMAL gate and really creates — the row lands and its own
303
- workspace opens for refinement (an EMPTY checklist, commons as ghosts). NEVER a creation
304
- wizard: create-then-refine puts complexity on the record surface, not in front of it. The create
305
- row sits BELOW matches by default (the keyboard highlight lands on the first MATCH, so Enter on a
306
- partial picks it, never a duplicate); pass `customOptionPlacement="top"` to pin it above. Use
307
- `reflectSelection={false}` and render the attached record below as a card with a Change action.
308
- For input-level status that must stay visible while results scroll (validity feedback on the typed
309
- value, a result count, a secondary "create" affordance), drop a `ComboboxFooter` into the content —
310
- a pinned row below the listbox, OUTSIDE keyboard option-nav; call `useCombobox().close()` from a
311
- footer action that hands off elsewhere (so the popover dismisses cleanly instead of floating over
312
- what you navigated to). A `ComboboxEmpty` child gives the no-match state richer content than the
313
- `ComboboxContent emptyText` string.
314
-
315
- ### Line items — create → preview → edit (a composition, not a primitive)
316
- For a list of records you build then revise (invoice rows, repair lines, config entries), each
317
- item is a `Card` toggling between a read-only PREVIEW (a stack of `DetailRow`s) and an EDIT form
318
- (the inputs), via a local `editing` flag + Edit/Save/Cancel in the `CardFooter`. A freshly-added
319
- item opens in edit; Save collapses it to the preview; Edit reopens it. **Edit is cancelable** —
320
- snapshot the item on Edit so Cancel reverts, or DISCARDS a freshly-added one. Duplicate sits left;
321
- the destructive **Delete is `danger-secondary`**; Cancel + the Save/Edit toggle sit right. Every
322
- screen composes its own (~15 lines) so the preview rows + form fit the data.
323
-
324
- ### Handoff — a stage transition, never a message
325
- When work crosses departments (sales → operations → accounting), the HANDOFF is the RECORD
326
- changing desks — never an inbox, a notification, or a copied task. Two types:
327
- **(A) Same entity** → a STAGE field on the shared record: sections carry OWNER dot tags, and
328
- the handoff is MANAGED AS TASKS — each desk's checklist on the record (the task-row vocabulary:
329
- sibling `CheckCircle` + struck transparent `InlineTextInput` + per-task `InlineMemberSelect`
330
- assignees + the `CaptureRow` add-affordance — never a decorative progress strip). A NEW record
331
- starts with an EMPTY checklist — tasks truly vary. The commons split in two: MANDATORY tasks are
332
- seeded by the app (a workflow on create, per record type) — no human types them; common-but-
333
- OPTIONAL tasks appear as `SuggestionChip`s under the list — a PILL, never a row,
334
- so a suggestion can't be mistaken for a task (tap = materialize, ✕ = dismiss for this record;
335
- already-present labels filter out; suggestions never count in done/total and pause while a
336
- filter narrows the view) — the same suggestion grammar as the
337
- billing Standard pill. Real rows carry a ⋯ `menu` (an `ActionMenu`; Delete lives BEHIND it, danger-styled and last — never a bare ✕ a stray tap can hit), completing the checklist's CRUD. Open
338
- tasks INFORM
339
- the handoff, they NEVER block it: the CTA stays enabled, the count warns, and open tasks carry
340
- over. The handoff CTA opens a DIALOG for the receiving desk (assignee `MemberSelect` + an
341
- optional note; the open-task warning inside); confirming closes the drawer — the record left
342
- this register. Tasks PEEK from the register: the done/total column is a pressable
343
- compact-`ProgressBar` trigger whose popover holds the checklist on FIXED columns — every row
344
- ring · struck `InlineTextInput` title (flex) · quick-reassign `InlineMemberSelect` (140) — no
345
- expandable rows (tags/files depth is the Task list template's lesson, not the peek's); the
346
- popover body is `PopoverContent`'s own ScrollView (`disableBodyScroll` is ONLY for children
347
- that manage their own scroll, like `OptionList`). The DRAWER carries a real Tasks SECTION in
348
- the Record template's shape (heading + the compact meter + the same checklist) — one shared
349
- task state per record feeds the column, the peek, the section, and the handoff dialog;
350
- a section owned by a later desk sits visible but GATED ("Billing opens when the record reaches
351
- Accounting"). Registers scope by stage — the receiving department's register IS its inbox. The
352
- sender's sections stay editable after handoff (the gate transfers RESPONSIBILITY, not access —
353
- locking is app IAM, not template grammar). Worked example: `tpl_record`.
354
- **(B) Different entity** (a deal → a shipment) → SPAWN-AND-LINK: the upstream record's terminal
355
- gate CREATES the downstream record on the shared spine, linked both ways (a linked-record row
356
- each side); usually TWO apps — the upstream closes its own lifecycle, the downstream starts
357
- fresh in its department's app. Never merge the two lifecycles into one record.
358
-
359
- ### Sequential phases on one record + the outline rail
360
- When a record's work happens in ORDERED PHASES (gate in → gate out; receive → dispatch), the
361
- phases are NOT tabs or a segmented control — hiding the other phase loses the context the
362
- current one needs and buries why its gate is blocked. Both phase sections sit on ONE page in
363
- chronological order; each phase owns ITS OWN fee rows + phase total (the invoice-band idea one
364
- level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
365
- its blockers ("Confirm gate in first."). The header status chip carries the phase. A LONG record
366
- surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav` (scroll-spy: jump
367
- to a section, the highlight follows the scroll); on narrow containers the rail becomes a
368
- PINNED bar naming the current section that opens a full-page section-picker `Modal`. A per-phase
369
- dot on a rail item carries its confirmed state. A direction that is a TYPE (one record per gate
370
- EVENT) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the create
371
- step, never a toggle on the record.
372
-
373
- ### Billing — the invoice DOCUMENT is the unit (`tpl_record` Billing section)
374
- When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
375
- split the screen into "enter fees here, issue there" — that smears one job across two places. Make
376
- **each invoice a FLAT hairline-set band that holds its own editable charge lines** (amount input +
377
- payment method), its **live total**, its **status badge** (nothing-to-bill · draft · issued + ref),
378
- and its **issue action** in its closing row — no card chrome; Data-capture templates are flat. A charge never lives apart from the document it bills on. Issuing is gated
379
- **inline, never a dead end**: when a prerequisite is missing (a bill-to tax ID, a method on a
380
- charged line) the issue button disables with one muted line saying what's needed; the payment-method
381
- picker turns required the instant a line carries an amount. Issuing a real e-invoice is irreversible
382
- → confirm in a `Dialog` (stage gate). A grand-total **receipt** validates first — surface the EXACT
383
- missing methods (`Alert.alert` listing each) rather than a vague "incomplete." A refundable
384
- **deposit** is its own card and its own receipt — never folded into the total due. Composition over
385
- `Card` + `NumberInput` + `Picker` + `Badge`; no new primitive.
386
-
387
- ### Tag / multi-value field — `Select multi`
388
- A tag field's resting state should be a tidy CHIP BOX — and that's just a multi `Select` whose
389
- `renderSelected` returns a removable `<Chip onDismiss={remove}>`. There's NO separate component and no
390
- `display` mode: `renderSelected(item, { remove })` composes the anchor — render a `Chip` with `remove`
391
- for the ✕, or a plain `OptionBadge`/`MemberChip`/custom pill that ignores it; `renderOptionContent`
392
- renders the menu rows. `searchable` adds the filter field, `allowCustom` the create row. Borderless for
393
- a grid cell? Pass a `style`, never a `variant`. `Combobox` is the SINGLE-value sibling — for a search
394
- that emits one pick at a time and renders the selection elsewhere, use `reflectSelection={false}`
395
- (see `tpl_*`).
396
-
397
- ### Disposition — lifecycle status is ASYMMETRIC by phase
398
- When a status is an OPEN default plus terminal OUTCOMES the user decides (Lead → Closed/Lost, Draft
399
- → Confirmed/Cancelled, Open → Approved/Rejected), a 3-way segmented/dropdown is wrong — it treats a
400
- lifecycle as flat peers and guides neither the decision nor the revision. Split by phase: while
401
- OPEN, surface the outcomes as ACTIONS with valence (`Mark closed` = `primary`, `Mark lost` =
402
- `danger-secondary`; the open state a quiet dot `Badge`). Coloring the buttons is right here — a
403
- SINGLE record-level decision is not the approvals-queue "wall of loud buttons." Once RESOLVED, show
404
- the colored STATE (`Badge variant="dot"` — emerald positive / red negative / blue open) with a
405
- quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each state a colored dot + label, current
406
- marked + disabled), not a generic text menu. A composition (Badge + Button + Popover/MenuButton).
407
-
408
- ### Attachments — a full add / preview / DELETE field
409
- **Default: `<FilesEditor files onAdd onRemove>`** — it bundles the grid + a toolbar (Upload · Select ·
410
- Download all) + a batch SELECT mode + the gallery + confirmed-remove; the host only owns `files` and
411
- wires `onAdd` (picked → its upload) / `onRemove`. The destructive per-tile ✕ shows only in SELECT mode
412
- (the default view is a clean preview — no stray-tap deletes); pass `selectTileRemove={false}` to drop
413
- that ✕ entirely so select mode deletes ONLY via Select → Menu → Delete (the batch gating flow — how the
414
- cell popover's `cell_files_editor` does it). The built-in gallery is Download +
415
- inline preview (no "open in new tab" — it's redundant once everything previews inline). In a
416
- height-bounded container (a popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar
417
- pins below it; omit it in free-flow layouts (a form field) where the grid grows. Reach for the
418
- lower-level pieces below only when you need custom chrome:
419
- Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
420
- accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`** ABOVE
421
- the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add more"
422
- affordance — only the empty state leads with it). `FileGrid` is the upload-aware grid: `files` are
423
- the saved/completed `DisplayFile`s, `uploads` is the LIVE add-queue (`FileUpload[]`) — it interleaves
424
- both and renders each in-flight tile itself — a LABELED status overlay (uploading spinner · "Retrying"
425
- · "Paused" · "Upload failed" + a retry button · "Can't upload" for a dead/empty file) — so you never
426
- hand-map an upload to a `FileThumbnail`. Localize the labels with `labels.upload` (an
427
- `UploadStatusLabels`; the Lotics `@lotics/ui-internal` adapter already injects vi/en). Make it CRUDable
428
- by wiring its callbacks: `onFilePress`
429
- → set a `number|null` index that drives `<FileGalleryModal files activeIndex onIndexChange>` — a
430
- FULL-SCREEN viewer with a toolbar (filename · counter · actions — download, optional
431
- `onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a
432
- phone (`useScreenSize().small`) the actions collapse into a `⋯` `ActionMenu` so the close-✕ never
433
- overflows off-screen (its popover portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks
434
- one, which is why an earlier inline-less popover rendered behind the overlay). The 90° rotate controls
435
- for images are NOT in the toolbar — they FLOAT as a pill overlaid on the image (every screen size).
436
- `onDisplayRemove`/`onUploadRemove`
437
- → drop the file / cancel the upload (the grid renders a ✕ on each tile automatically); `onRetry` /
438
- `onRetryAll` for failed uploads. In a sandboxed custom-code app the toolbar's "open in new tab" can't
439
- pop a window — pass `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the
440
- action hides). PDF/Word/Excel/CSV all render INLINE here (pdf.js / `@lotics/docx` / `@lotics/xlsx`,
441
- lazy) — no native `<iframe>` viewer, so they work inside the cross-origin app iframe. Keep
442
- label+grid+dropzone on a `gap` (a bare `CardBody` has none). By default `FileGrid` tiles FILL the
443
- container width — a uniform size (≥ `minItemWidth`, default 96) so full rows span the width and reflow
444
- as it changes; a short last row keeps that size and left-aligns (no stretched tiles, no empty cells).
445
- Pass `columns` for a fixed column count, or `itemSize` for exact fixed-size tiles. For a dense strip
446
- in a FIXED-width container that must NOT wrap, pass `singleRow` — it fits as many tiles as the width
447
- allows and collapses the rest into a clickable "+N" overflow tile (`onOverflowPress(hiddenCount)`,
448
- which the host wires — e.g. open the gallery at the first hidden file). This is the responsive overflow
449
- pattern (avatar stack / Finder); prefer it over a hardcoded `maxVisible`, which can't adapt to width.
450
- Under the hood it composes `FileThumbnail` (the completed tile — right surface per
451
- MIME: image thumbnail · a doc tile with the `FileBadge` centered + a single-line filename · media
452
- card; also takes `isTemplate` to overlay a TMPL marker) and `UploadingThumbnail` (the in-flight tile);
453
- reach for either directly only when hand-rolling a NON-grid layout. **When the filename must be
454
- readable, use `FileRow`** — a horizontal LINE (`FileBadge` or a `placeholder` + the FULL name + a meta
455
- line + a composable `trailing` slot). `onPress` makes the whole row a pressable door (open the file);
456
- `trailing` (a status `Badge`, an action, a remove ✕) stays an independently-pressable sibling. For
457
- attachment lists / message files / document checklists where a square tile truncates the name. Never hand-build a drop well, a file grid, the upload tiles, or a preview
458
- modal. In a custom-code APP, don't hand-roll the upload+preview state either — `@lotics/app-sdk`
459
- `useAttachments()` gives the chat's instant-preview lifecycle (local object-URL now, `file_id` on
460
- complete); map each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file: {…} }`,
461
- else `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `FileGrid`'s
462
- `uploads` — `onUploadRemove={remove}`.
463
-
464
- **Gated file CRUD is a PATTERN, composed locally — not a sealed kit component.** When a file field must
465
- GUARD deletion (operator-facing, accidental delete is a real risk), DON'T use `FileGrid` (its per-tile
466
- ✕ is exactly the stray-tap risk you're guarding against) — compose it in the app from the kit pieces;
467
- don't reach for a one-size widget (the action set + layout vary per app — the host frontend's
468
- `files_editor` and each app compose their own). The recipe: `FileDropzone` (add) + `FileThumbnailGrid
469
- files selectedIds onFilePress` (pass `selectedIds` ONLY in select mode) + `FileGalleryModal` (view +
470
- rotate + persist) + a `Dialog` confirm, all driven by **`useSelectionMode()`** — the reusable LOGIC. The
471
- gating rule: **NO per-thumbnail ✕** — delete is **Select → ⋯ menu → Delete → confirm**, so a stray tap
472
- never removes a file. Three reusable primitives back it (the logic is shared; the action bar + layout +
473
- copy stay local):
474
- - **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit, toggle,
475
- toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's `selectedIds`).
476
- - **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
477
- (mobile → Save to gallery / send to an app), else individual `downloadFileFromUrl` — **never a ZIP**.
478
- It shares the BYTES (fetches each URL → `File`), so URL expiry afterward is moot; the share path needs the
479
- host iframe to grant `allow="web-share"` (else it falls back to download). Best for FAST urls (presigned
480
- R2). **For SLOW urls (auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
481
- {credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])` runs
482
- `navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch burns the
483
- tap's transient activation**, which silently lands in the download fallback. `prepareShareFiles` throws on
484
- a fetch failure (log it, don't swallow); `shareFiles` returns `"shared"|"cancelled"|"unsupported"`.
485
- - **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation into a
486
- NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is persisted). Pair with
487
- `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a rotated image).
488
-
489
- ### Stage gates — tiered by weight
490
- A transition that needs NO input is one click. 1–3 quick fields → a POPOVER FORM anchored to its
491
- action button (title + one-line stake + `FormField`s; confirm in `PopoverFooter`). A
492
- destructive/exception path (anything touching money or locks) → a `Dialog` (consequence in prose,
493
- danger confirm + cancel in `DialogFooter`) or an `Alert.alert(title, message, [{cancel},
494
- {destructive}])` confirm. Never an inline expanding panel for a gate — it shifts layout and loses
495
- the "this is a gate" framing.
496
-
497
- ---
498
-
499
- ## AI workflows — AI proposes, the human decides
500
-
501
- The AI surfaces share ONE law: the agent never commits — it **proposes**, the human
502
- accepts / edits / dismisses, the deterministic app applies. The agent owns judgment
503
- (recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
504
- Compose the surfaces as a loop, and reach for the right one by job:
505
-
506
- **Which AI surface — decide by the OUTCOME's shape, not the task's topic.** Three shapes:
507
-
508
- 1. **Field writes** (extract, match, rank, classify → the record changes) → **in-app agent**
509
- (`useAgentRun`) + **`ChangeReview`**. The commits must obey the diff law (Keep/Drop per
510
- change, app-workflow writes, bounded app authority), and the operator wants one button +
511
- a review — never a prompt box.
512
- 2. **Evidence for an in-app decision** (cross-check, audit, tie-out → nothing is written; the
513
- operator acts on what was found) → **in-app agent** + **`Finding`**. Still bounded and
514
- prompt-free (an optional instructions brief at most): the brief is fixed, the output is
515
- structured display-only findings read IN the record's context, and there is no conversation
516
- to have — the loop closes when the operator acts in the app. Chat would add prompting and
517
- tear the findings away from the record they judge.
518
- 3. **A file or an open-ended answer** (edit this document, draft from context, explain) →
519
- **hand off to the chat agent** (`askAi` in `@lotics/app-sdk`). The loop is multi-turn with
520
- no output schema, judged by looking — and the chat harness already owns it: preview beside
521
- the thread, version chains, branching, session memory. An in-app "edit chat" would
522
- re-implement all of that inside every app.
523
-
524
- The Document desk's Use-AI fork IS this table as UI: Extract data (1) · Cross-check (2) ·
525
- Edit with AI (3) — one entry point, three outcome shapes.
526
-
527
- - **Command / compose** — `Composer`, the adaptive command surface. Empty + minimal (no `footerRight`
528
- model picker, no `pills`, no `files`), it's a COMPACT PILL: an optional `actionsButton` (attach) left,
529
- the input, a send arrow right (Enter sends, Shift+Enter for a newline) — the same geometry
530
- `AgentProgress` morphs into. Type past one line, or add files, and it EXPANDS: the input lifts onto
531
- its own row, the `files` slot (`FileThumbnail`s, each `onRemove`-able) and `pills` stack above, and
532
- the buttons drop to a footer row (the expansion latches until the text is cleared). For a run that
533
- needs a FILE + a prompt together (attach a photo, review/remove it, add a note, THEN send), fill
534
- `actionsButton` + the `files` slot and set `sendDisabled` so Send fires with an attachment and/or
535
- text (`onSend(text)` — the host holds the uploaded file id). **Never auto-run an agent on attach** —
536
- attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
537
- way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
538
- a morph).
539
- - **Show the work** — `AgentRun`: a live feed of the agent's work as a TIMELINE. The prop is an
540
- ORDERED `items` array — `{type:"text"}` prose (rendered as `Markdown`, a trailing ▍ while live),
541
- `{type:"reasoning"}` thinking, and `{type:"step"}` tool/step calls, in the order they happened (a
542
- real run is think → text → a burst of calls → more text, NOT all text on top of a flat step list).
543
- **Progressive disclosure** — the feed shows the label + state; the detail is revealed on demand:
544
- `reasoning` renders COLLAPSED (a muted "Thinking" row, press to reveal the Markdown), and a `step`'s
545
- `input`/`output` are hidden in the row but open in a press-to-reveal **peek** (auto-built Input /
546
- Output `JsonPanel`s; an `error` status + `errorText` paint the row red and show the reason there).
547
- Pass an explicit `peek` node to render that reveal yourself (it overrides the auto panel); `detail`
548
- is OPTIONAL — a short human summary (a count), never invented prose or raw I/O (that's `input`/`output`).
549
- Consecutive `step` items fold into ONE activity group: while the agent is mid-tools the tail group is
550
- a SINGLE pulsing row whose label swaps in place as each call fires (no growing stack of dots); once
551
- prose resumes the group settles into a persistent "{final action} · {n} steps" HEADER (a `complete`
552
- terminal dot — distinct from the filled `done` step dots) that STAYS PUT and rolls the calls out
553
- BELOW it on press. The run ALWAYS ends on the agent's text (no global terminal node). A `step` with
554
- `kind:"tool"` carries the RAW tool name, resolved via a built-in map + an optional `labelForTool`
555
- override (localize THERE — the kit stays English); `stepsLabel` localizes the "{n} steps" suffix.
556
- Fed natively by `@lotics/app-sdk` `useAgentRun().items` (reasoning + per-tool I/O + state come for
557
- free — no hand-assembly). Transparent work, NEVER a bare spinner. On a canvas/composer app reach for
558
- `AgentProgress` — `AgentRun` collapsed into a floating pill (avatar + current step) that EXPANDS on
559
- press; the composer morphs into it while running, and reveals again when done.
560
- - **Review before apply — ONE surface, the compound `ChangeReview` family.** Every "the agent
561
- proposes → the human accepts / edits / dismisses → nothing auto-applies" surface composes from
562
- twelve pieces in three groups. **Frame**: `ChangeReview` (context provider + stack; hairline
563
- dividers between `Change` sections — and `ChangeFields` rules between fields) ·
564
- `ChangeReviewHeader` (the SECTION heading — md semibold; keep it lean: never repeat the record
565
- the dialog is already about) · `ChangeReviewActions` (the commit bar — pin it in the
566
- `DialogFooter`/`DrawerFooter`; **Keep all** bottom-left presses every pending decision — pass
567
- `onAcceptAll` when field state lives in the host; the "N of M kept" counter reads HERE beside it,
568
- never in the header; Apply disables at 0 kept or via `applyDisabled`). **Sections**: `Change`
569
- (heading + free body + optional verbs + collapse — omit both callbacks for display-only) ·
570
- `ChangeLabel` · `ChangeSummary` (the collapsed row's content) · `ChangeReasoning` (the agent's
571
- quiet hairline aside — only when not self-explanatory). **The grammar**:
572
- - **`ChangeFields` + `ChangeField`** — THE field unit. `ChangeFields` is the OPEN form (the
573
- section IS the record — an extract dialog's one order: no card chrome). Each `ChangeField`:
574
- label (sm, muted — the DetailRow convention) · the `−` band when replacing (`before`) · the value — `ChangeValueInput`
575
- by default (the green `+` band; pressing it edits IN THE BAND — a borderless input with
576
- identical type metrics, so nothing shifts; `unit="pcs"` fixes the suffix outside the editable
577
- core — type the number, never the unit) or any input · conflict `candidates` as full-width decision rows + the localized
578
- "Type another value" third option (`custom*` props; the outcome band is read-only via
579
- `valueReadOnly` — the decision comes from picking, never from editing the band; gate with
580
- `keepDisabled`) · row `reasoning` LAST · per-field **Keep/Drop** bottom-right
581
- (`status`/`onKeep`/`onDrop`/`onUndo`). A pure REMOVAL is `before` with an empty value — the
582
- `−` band alone. A decided field collapses to the compact one-row card (mark · label →
583
- `summary` · Undo).
584
- - **`ChangeRecord`** — THE item card for SETS of records (order lines): `id` (it registers in
585
- the review context like a `Change`), `tone` add/edit/remove, the tinted header band with the
586
- localized op word (Add/Edit/Delete · Thêm/Sửa/Xóa) + title, body = its `ChangeField`s, and
587
- **the verb level follows the decision level**: add/remove carry ONE card Keep/Drop (fields
588
- none — editable parts, one write); edit carries NO card verbs (only its changed fields, each
589
- deciding for itself — dropping a field narrows the update diff). Collapses via `summary`.
590
- - **`ChangeBand`** — the raw diff band (aligned `+`/`−` marker column, light emerald/red, dark
591
- text) every piece builds from; reach for it directly for custom strokes.
592
- **The scaling boundary (known, deliberate)**: `ChangeFields` + `ChangeRecord` cards cover one
593
- record through ~10; a BULK review (30+ imported rows — a bank statement, an Excel import) needs a
594
- dense form that does not exist yet — design it against the first real migration, not
595
- speculatively.
596
- **The laws**: ONE verb pair everywhere — Keep/Drop, localized; never rename per shape (Apply is
597
- the only outcome-named button). Candidate rows carry the VALUE only; provenance = `Sources` at
598
- the VERY bottom of the review (after every change; or omit it — lean beats decorated). The HOST owns every decision in plain `useState`; the family owns the
599
- mechanics (collapse + Undo, the counter, Keep-all, apply gating). Never apply a field with no
600
- value — gate unresolved conflicts. Editing IS the review.
601
- - **Ask back** — `Clarify`: when the agent is unsure, it asks a question with quick-reply options
602
- and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run.
603
- - **Provenance** — `Sources`: openable chips saying where the output came FROM (records, a
604
- document + page, a table), under any answer / summary / extracted value. Makes AI output
605
- verifiable — show it on anything produced from data the agent read.
606
- - **Session, not chat** — the outputs accrue as a history the user can CLEAR ("New session"); the
607
- APP owns the evolving state, each run is a discrete task. (The agent may re-read the session for
608
- "make it a bit less", but it's a run LOG, not a conversation transcript — that's why it's not a
609
- chat.)
610
-
611
- Ten shapes prove the range. The first four PRODUCE — pick by whether the work is a living artifact,
612
- a conversation, a single pass, or generated prose:
613
- - **Canvas** (`tpl_dieline`) — the page IS the design on a pannable/zoomable surface: it stays
614
- CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom (a
615
- `Composer`: attach a photo → review/remove it → add a note → send, with `sendDisabled` allowing a
616
- file-and/or-text submit — NOT auto-run on attach) morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (a label/value field-card
617
- in live-edit mode, carrying the single Download; minimizes to a pill) floats centre-right. Panel, zoom pill and composer
618
- float ON TOP — they never shift the design. (Floating layers use a `pointerEvents:"none"` wrapper
619
- with the interactive child set `"auto"`; RN-Web ignores `"box-none"` in style, so a full-width
620
- wrapper would otherwise eat clicks on the canvas behind it.) Change the design by prompt ("5 mm
621
- taller") OR by editing a param directly — either re-flows it in place. For a design/document the
622
- user shapes over time.
623
-
624
- The next six output STRUCTURE the first four can't — when the answer is a panel of facts, a queue of
625
- decisions, or a ranked set, don't cram it into chat prose. Each pairs a template with a primitive:
626
- - **Answer desk** (`tpl_lookup`) — describe the goods on the LEFT → the agent RANKS the
627
- matching codes (NEAREST MATCHES, the top one Recommended) → pick one and its STRUCTURED answer pins on
628
- the RIGHT (a verdict header + an exact breakdown + the policies + sources). Input → matches → pick,
629
- NOT a single confident verdict: classification is ambiguous, so the alternatives are first-class and
630
- picking a different one changes the duty; refining the description re-ranks. For look-up-and-explain:
631
- tariff/HS, fee lookup, policy Q&A, a spec/compliance desk. (NOT a chat with the answer in a bubble.)
632
-
633
- - **Document desk** (`tpl_documents` — THE go-to for document-driven records) — the record's files
634
- block feeds ONE "Use AI" entry that FORKS into the two document tasks, each a specialized run with
635
- a task-pure result: **Extract** (files read → fields already matching fold into one quiet line →
636
- every add / update / conflict a `ChangeField` (struck before · bordered input · candidate chips),
637
- the record's current value a first-class choice — plus proposed new lines as record-body `Change`s
638
- → one outcome-named `ChangeReviewActions` commit) and
639
- **Cross-check** (documents compared against the record and each other → ranked `Finding`s —
640
- severity · title · the prominent metric · sources — separated by hairlines; the findings ARE
641
- the outcome the human acts on — no phantom "record verdict" write, a persisted check-status
642
- goes stale on the next edit). The fork carries an OPTIONAL instructions field — the user steers what the agent
643
- checks or extracts, so `Finding` serves ANY file-based AI request, not just the stock
644
- cross-check. Every file list in the flow opens the full-page `FileGalleryModal`. Plus **Create documents**: a readiness checklist (unchecked by
645
- default, missing inputs called out) → generate → the files land back on the record. Never merge the
646
- two AI tasks into one mixed output — the fork is the design.
647
- - **Triage** — an inbox the agent classified + routed is a `ChangeReview` of `Change`s (body: the
648
- item + the agent's call; verbs Accept / Dismiss; Accept-all covers the high-confidence sweep).
649
- - **Compare / ranked pick** — one `ChangeField` whose `candidates` carry the ranked options
650
- (`description` = the score/reason, `source` where it came from); the human picks one. Quotes,
651
- carriers, suppliers, plans.
652
-
653
- The AI vocabulary has **no purple accent and no gimmick glyphs** (no sparkles) — but it is NOT
654
- monochrome: **colour is used where it carries meaning, not for decoration.** What the violet sparkle
655
- used to carry now reads structurally — **provenance** is an uppercase microlabel naming the artifact
656
- (`PROPOSED` · `MATCH` · `MISMATCH` · `SUGGESTED EDIT` · `QUESTION`), and the agent's **reasoning** is
657
- a left-ruled margin note (a hairline rule + muted text), quoted apart from the facts and the human's
658
- controls — while **status/severity/diffs use functional colour** the way the rest of the kit does:
659
- - **Confidence** — a 3-tick meter + the full phrase ("High confidence" / "Medium" / "Low"), emerald /
660
- amber / zinc by level; `labels` to translate (one phrase per level).
661
- - **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
662
- centre), `done` a filled accent dot + **white check**, `upcoming` a faint **grey** ring, the terminal
663
- `complete` a **blackish ring + black check**, `warning` **amber**. Default accent is neutral ink
664
- (`color` themes it); `AgentRun` keeps the neutral default — an active group's tail pulses (`current`),
665
- a settled group is a `done`/`warning` row, and there is no global terminal node (the run ends on text).
666
- - **ChangeBand** — the removed value on the light **red** band with the `−` marker, the incoming
667
- value on the light **emerald** band with `+` (the GitHub-diff idiom, markers in one aligned
668
- column); everything else in a review stays neutral — a decided row reads a single emerald check,
669
- an add/remove record card wears the quiet tone wash (50 body · 100 header · 200 border). Colour
670
- marks the change, never the chrome.
671
-
672
- Card chrome (borders, microlabels) stays neutral — colour marks the *state*, never the container.
673
- The **composer keeps its icons**: `Composer` compact is a single-row pill with an optional circular
674
- attach `actionsButton` + a circular send `Button`; expanded it adds a `FileThumbnail` attachment row
675
- (each `onRemove`-able) above the input; `AgentProgress` is the `WaveAvatar` pill. "No icons"
676
- was only ever about the review surfaces' sparkles/severity glyphs, not functional affordances.
677
-
678
- ---
679
-
680
- ## Composition grammar
681
-
682
- - **Canvas**: full-bleed `colors.zinc[50]` ScrollView; content column `maxWidth` 880–1040,
683
- centered, `padding: 28`. Cards float on the canvas — never white-on-white.
684
- - **Heading vocabulary — ONE construct per altitude, no drift.**
685
- · **Page band**: one per screen — `Text size="xxl" weight="semibold"` title (`#`) + one `sm muted`
686
- subtitle that says what the screen decides. Right side: the screen's ONE primary action and/or
687
- a period filter — never a summary Badge (those belong to the KPI strip).
688
- · **Card header** (a card's own title band, or separate banded cards): `CardHeader` +
689
- `CardHeaderTitle` (`sm semibold`; `info` when the title alone doesn't define the numbers).
690
- · **Section title** — ONE construct: `Section` > `SectionHeading` > `SectionHeadingTitle`
691
- (lg semibold + optional muted `description`/`info`), everything left-aligned at the column
692
- edge. The page column is a `SectionStack` — it owns the between-section law (`gap: 32` +
693
- a bare hairline separating one section from the next); the `Divider` NEVER goes directly
694
- under the heading, which orphans the title from its own content. Inside a DRAWER, a full
695
- multi-section WORKSPACE uses the real
696
- `SectionHeading` (lg) — its meta + CTA slots carry each section's count and action; a
697
- `Text size="sm" weight="semibold"` stand-in is only for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT
698
- hand-roll sm-semibold lead lines on a flat page. NEVER a bare eyebrow as a section title.
699
- · **The heading ramp is FIXED — the markdown ladder, no size knobs.** `#` = xxl
700
- (`RecordSummary` / `PageHeader` title) → `##` = xl (`SectionHeadingTitle`, always) →
701
- `###` = lg (`SubsectionHeadingTitle`, always) → body sm. One outline on every surface;
702
- never restyle a heading level per-page.
703
- · **Subsection title** — the level BELOW a section on a long record surface: `Subsection` >
704
- `SubsectionHeading` > `SubsectionHeadingTitle` (`###` lg semibold; siblings — a `Badge`, a
705
- `SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections
706
- stack in a `SubsectionStack` (24 + hairline between groups — no margins, no hand-rolled
707
- dividers); a headingless `Subsection` is the section's lead group. Group leads INSIDE a
708
- subsection's rows are `md medium` at most. Do NOT hand-roll `Text weight="semibold"` group
709
- leads inside a section, and never promote a subsection to its own section-level heading
710
- just to separate it.
711
- · **Eyebrow / label** (`<Text size="xs" color="muted" weight="medium">` — **sentence case, NEVER
712
- `transform="uppercase"`**): a small quiet label above or beside content — an artifact tag
713
- ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
714
- label. **All-caps is banned — it reads as shouting and the redundant uppercasing of every little
715
- label is the #1 thing that makes a surface feel templated.** Sentence case + medium weight, full
716
- stop. (A COLORED status word — a verdict like "Mismatch" / "Resolved" — keeps the same xs/medium
717
- shape with a status `color`, still not uppercase.) This applies to every hand-written label; don't
718
- reach for a wrapper component either, just write the `Text`.
719
- · **Gate header**: a `Dialog` uses `DialogHeaderTitle`; a popover form uses `Text size="sm"
720
- weight="semibold"` + an optional `xs muted` subtitle.
721
- - **Time-constrained data gets a period filter** in the header band — `DateRangeFilterField`, never
722
- a static period badge. Every period-dependent number MUST follow the selection. Pass `includeTime`
723
- when the time-of-day matters: the trigger previews the chosen time (locale-aware 24h/12h) and the
724
- hour/minute selects label themselves from the `labels` prop (`hour`/`minute`/`dayPeriod`).
725
- - **Keyboard & focus — use `tabIndex`, never `focusable`.** RN-Web's `Pressable` silently ignores
726
- `focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
727
- `tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
728
- (Tabs/SegmentedControl/RadioPicker) keep ONE stop at `0`, the rest `-1`. And NEVER let a FOCUSED
729
- control unmount — a conditional pointer affordance that vanishes on use (e.g. an "apply suggested
730
- value" pill shown only while a field is empty) must be `tabIndex={-1}`, or the browser drops focus
731
- to `<body>` and the next Tab jumps to the page's first focusable. Full rules + the keyboard test:
732
- `docs/accessibility.md` → Focus & tab order.
733
- - **Focus rings are per-component, never global.** There is NO global focus CSS — `index.css` only
734
- resets the native outline. Every interactive control paints its OWN ring; nothing rings unless it
735
- opts in. Three ways, pick by base:
736
- - **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on keyboard
737
- focus, WITHOUT the hover wash; the base for any control whose hover affordance is its BORDER not a wash
738
- (inputs/selects, the inline editors, cells, tiles, nav buttons, menu options). Forwards all
739
- `PressableProps`; its state-fn `style` exposes `hovered`/`focusVisible` so the control paints its own
740
- border-hover. Reach for this before hand-rolling a Pressable + the hook.
741
- - **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible` from
742
- its style-fn / children for a bespoke treatment, e.g. `CardSelectItem` which rings on hover/press/focus).
743
- - **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom surfaces.
744
- Returns `{ focusVisible, focused, focusProps }`; spread `focusProps`, apply
745
- `focusVisible && { boxShadow: FOCUS_RING }` (comma-join any existing box-shadow). `useFocusRing({ always: true })`
746
- for text-like inputs/selects (ring on ANY focus, as browsers do for typing-capable fields); plain
747
- controls omit it (keyboard-only).
748
- The ring is the shared **`FOCUS_RING`** token (`@lotics/ui/control_surface`, `0 0 0 2px zinc-900`). A
749
- mouse-opened popover trigger (Select/Combobox/InlineSelect/InlineDatePicker) wears the SAME token on
750
- its open state so it reads identically to a keyboard-focused control. **Coverage is a contract:** EVERY
751
- interactive control must ring (a focusable control with no focus treatment is a bug — the per-Pressable
752
- audit is the gate); a surface that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
753
- - **Hover intensifies a control's OWN resting signature — never a foreign affordance. Pick the base by WHAT THE SURFACE IS; never a raw `Pressable`.** The one rule behind every interactive control:
754
- - **Fields** (type into / pick from) — `TextInputField`, `Picker`, `Select`, `Combobox`'s input,
755
- `NumberInput`, `DateField`/`TimePicker`, `DateRangeFilterField`, the inline editors
756
- (`InlineSelect`/`InlineDatePicker` via `InlineEditView`) — signature is a BORDER → hover DARKENS it to
757
- `HOVER_BORDER` (a borderless in-cell field REVEALS one). Build on **`FocusRingPressable`** (ring + a11y
758
- + a `state.hovered`/`focusVisible` style-fn, NO wash); layer the hover-border AFTER `style` so it wins
759
- over the caller's resting edge; put the open ring (`FOCUS_RING`) on the open state. **A field NEVER
760
- greys its content.**
761
- - **Actions** — `Button`, `IconButton` — signature is a FILL → hover DARKENS the fill (own colour logic).
762
- Never a border, never a wash.
763
- - **Pills / toggles** — `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`,
764
- `Tabs`, `Switcher`, `RadioPicker` — signature is a pill SURFACE → hover WASHES it (white→zinc-100);
765
- selected adds a ring/fill.
766
- - **Surfaces** — `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Card`, `Accordion` —
767
- signature is a row/card SURFACE → hover WASHES it. Build on **`PressableHighlight`** — the grey wash IS
768
- the affordance (its whole job; it's in the name).
769
- - The bug this prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border →
770
- it greys *and* animates its edge while its siblings only border-hover (`Select`/`DateRangeFilterField`/
771
- the inline editors did exactly this pre-7.x). Reach for `PressableHighlight` on a bordered/input control
772
- and you've made it.
773
- - **Cards are banded — and composable** (all from `@lotics/ui/card`):
774
- ```tsx
775
- <Card style={{ padding: 0 }}>
776
- <CardHeader><CardHeaderTitle info="what this shows">Title</CardHeaderTitle><CardHeaderMeta>12</CardHeaderMeta></CardHeader>
777
- <CardBody>…</CardBody> {/* repeat with <Divider/> between */}
778
- <CardFooter>hint(flex:1) + actions</CardFooter>
779
- </Card>
780
- ```
781
- Never nest cards; never hand-compose a title or footer band. `info` is standard, not garnish.
782
- - **Flat work execution & data capture vs carded monitoring.** A `Card` is ALWAYS a bordered,
783
- lifted container — there is no "flat card". MONITORING / dashboard screens group with cards (and
784
- the `KPIStrip` band). WORK-EXECUTION screens — registers, lists, worklists — AND **data-capture /
785
- record screens (forms, wizards, billing, settings, inline records) are FLAT**: content sits
786
- DIRECTLY on the white canvas with no Card wrapper; separation comes from the row wash, gaps,
787
- `SectionHeading` + `Divider` rhythm. Reach for a Card only
788
- where a screen holds a DISTINCT region that must be told apart from another; a single register or
789
- list filling the screen needs none. ONE carve-out inside a flat form: **repeating SUB-OBJECTS
790
- (line items) are each their own small Card** (`padding: 16`), so discrete items
791
- read as discrete objects; hairlines between them blur into one run-on list.
792
- - **Summaries — two altitudes, never mixed.** A DASHBOARD opens with the boxed stat band
793
- `<KPIStrip items={[{label, value, trend?, caption?, tone?, info?}…]}>` (a `Card` of `KPICard`
794
- columns). A REGISTER/LIST (work-execution) screen instead carries a LIGHT inline
795
- `<SummaryLine items={[{label, value, tone?, info?}…]}>` BELOW its toolbar — a small `Metric` + a
796
- muted label per item, recomputed from the FILTERED rows so it always describes WHAT'S SHOWN. Both
797
- are INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if there are no
798
- cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
799
- a dashboard. Card stat rails use `KPICard`.
800
- - **Numbers**: free-standing numerals `<Text tabular>`. Money: `formatMoney(n)` from
801
- `@lotics/ui/format_money` (`compact` for strip captions; it emits a non-breaking space before `₫` —
802
- normalize `\s` in test asserts). Dates: `formatDate(value, opts)` from
803
- `@lotics/ui/format_date` — the date-VALUE formatter. `format` (date STYLE) ∈ `date` (22/05/2026) ·
804
- `medium` (22 thg 5, 2026) · `long` (22 tháng 5, 2026) · `dayMonth` (22 thg 5) · `monthYear`
805
- (Tháng 5 2026); **`time: true` is orthogonal** — prepends the 24h time to ANY style
806
- (`14:30 22/05/2026`, `14:30 22 tháng 5, 2026`); `compact` drops the year on `date`. It's `Intl` +
807
- the product's conventions (stable `/`, naive-ISO parse, 24h time-first, `emptyLabel`) — never
808
- hand-roll a date via `Intl`/`padStart` for value display. An order the presets lack (e.g.
809
- time-first `HH:mm dd/MM/yyyy` in a doc builder): `parseDate` + assemble the missing order —
810
- never a regex re-implementation. (Exempt: a component's own internal
811
- chrome — a calendar's header/weekday/a11y labels, a gantt axis — renders its own set.)
812
- - **Every number is a door** (except the KPI strip). A component that summarizes records leads to
813
- the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
814
- Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
815
- (`AccordionHeader`/`AccordionTitle`/`AccordionMeta`/`AccordionContent`); header-only = a plain row
816
- of identical rhythm, so lists mix expandable + static rows. The body is **flush** with the
817
- header's left edge (aligns with the title — like a `Section` body, no indent). `AccordionHeader`
818
- is a layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
819
- **collapsible Section** (configurable font/level/description + count) compose the SAME
820
- `SectionHeadingTitle`/`SectionHeadingMeta` inside it — one heading family across static + collapsible.
821
- - **No dead rows.** Every listed record is actionable. PRIMARY entity rows press-open the workspace
822
- `Drawer` (sequenced); read-only drill-downs expand via `Accordion` or glance via `Peek`; every
823
- other row gets an `ActionMenu` (⋯ → MenuButton items: destructive last + **confirmed** via
824
- `Alert`). The whole surface is the door: register rows are `PressableRow` (full-bleed wash incl.
825
- nested controls), Divider-separated:
826
- · **Register (default, `variant="register"`)** — the rounded full-width hover/open/`marked` wash
827
- spans the whole row (incl. nested controls); content sits on the 20px gutter so a `Table` header
828
- + cells align. The Divider BETWEEN rows is the resting separation. The COLUMNAR register is
829
- `Table`/`TableRow`/`TableCell` (columns once → header + widths can't drift; ONE hairline under the
830
- header; the rows below it Divider-separated; a sortable column shows its `SortHeader` sort glyph
831
- ALWAYS so it reads as sortable). A `TableRow` with no `onPress` renders a STATIC read-only row (no
832
- hover wash / pointer cursor) so `Table` also serves read-only tabular data (a fee breakdown, a spec
833
- sheet), not just interactive registers. A pressable `TableRow` REQUIRES `accessibilityLabel`
834
- ("Open …") — its keyboard door is an empty overlay with no content to derive a name from. Make it SELECTABLE with the `Table` `leading` gutter + `selectAll`
835
- slot — a `CheckboxInput` per `TableRow` (`leading`) + a select-all in the header band, the ticked
836
- rows `marked`, paired with a `FloatingActionBar`; the selection state is the **`useSelection()`**
837
- hook (`selected`/`count`/`toggle`/`setAll`/`allSelected`/`indeterminate`/`clear` — gating which
838
- rows CAN be ticked stays with you: pass only the selectable ids to `setAll`/`allSelected`/
839
- `indeterminate`). `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination` in the
840
- `CardFooter`).
841
- · **`variant="bleed"`** (legacy) — px-20 square, `Divider`-separated; only for an edge-to-edge data
842
- grid that genuinely wants hard rules.
843
- · **`variant="inset"`** — grouped lists in a padded container (Accordion drill-downs).
844
- Right-hand columns align only if every trailing element is FIXED-width (give each trailing action
845
- a fixed `width`, so amount/status columns don't jitter).
846
- - **Row actions are ALWAYS-VISIBLE siblings, never hover-revealed, never nested.** A pressable row =
847
- a role-less surface (the hover/press wash, via DOM `mouseenter` so it covers the whole row) + an
848
- accessible "Open …" door (a button) + trailing actions (⋯, remove ✕, edit) as SIBLINGS of
849
- the door — a button must not contain another button, so the action is independently pressable and
850
- the ✕ isn't swallowed by the row press (`FileRow` and `PressableRow` are the references). The door
851
- wraps the row body ONLY when that body is non-interactive by construction (`FileRow`'s name/meta);
852
- where the body is arbitrary app content — `TableRow`'s cells, which legitimately carry a `Link` or
853
- a popover trigger — the door is an EMPTY absolutely-positioned sibling under the cells (tab stop +
854
- name + focus ring; mouse rides the surface), so a control in a cell never nests inside it. Actions
855
- stay **visible** — a hover-only action is invisible to keyboard and touch users. The ONLY exception
856
- is a DENSE tree/register where a persistent per-row action would clutter (e.g. the DB sidebar):
857
- reveal on hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its
858
- opacity, so it's still keyboard-reachable. Do that locally — there is no shared hover-reveal
859
- primitive (the old `HoverAction` made the keyboard-inaccessible anti-pattern easy and was removed).
860
- - **Master-detail = list + workspace `Drawer` with sequencing.** Pressing a PRIMARY entity row
861
- opens the record workspace in a `Drawer` with `onPrev`/`onNext`/`position` ("3/24") over the
862
- visible ordering (←/→ built in). Key the drawer body by record id so per-record state resets on
863
- step. Facts are `DetailRow`s; the commit bar is a `DrawerFooter`. The open row shows a selected
864
- highlight (and `marked` — a resting blue tint — for a bulk-ticked row). `Peek` is ONLY for
865
- secondary references, never the primary row press.
866
- - **Two work shapes, no side panels.** A work screen is ONE of two shapes — never a persistent
867
- side-by-side split (it dies on a phone, and the "detail" of a list belongs in a `Drawer`/sheet,
868
- not a second column). (1) **Work each** — a flat register/list; press a row → its workspace
869
- `Drawer` (above). (2) **Act on many** — a flat list with a leading `CheckboxInput` per row + a
870
- select-all `CardHeader` band, plus a bottom-pinned `<FloatingActionBar count label onClear>`
871
- carrying the bulk action(s) while ≥1 row is ticked (`tpl_item_list`); a row
872
- that can't take the action has NO checkbox. Bar weight gradient, left → right: the built-in
873
- Clear is muted (the quietest act), destructive bulk = `danger-secondary` + icon, secondary
874
- bulk = `secondary` + icon, ONE primary CTA last — never repeat the count in a CTA title; the bar's label already carries it. When a region genuinely must sit beside the list (a
875
- source/remainder summary, a capacity picker) make it a TOP summary header or move it into a
876
- Popover/sheet at the decision point — not a standing column.
877
- - **One control radius** — every interactive control (`Button`, the inputs/selects/pickers,
878
- `SearchInput`, `MenuButton`, `Tabs`, the `SegmentedControl` track, `ChipGroup`/`FilterChip`/
879
- `Chip`) wears `CONTROL_RADIUS` (10, from `control_surface.ts`) — never a literal. Containers
880
- are deliberately larger (`Card` 16, the review card 12). An element NESTED inside a padded control
881
- (a segmented thumb, a combobox chip, an ✕ on a chip) takes `CONTROL_RADIUS − padding` so its corner
882
- stays CONCENTRIC with the parent — set it equal and the inner corner bulges past the outer curve.
883
- Round-by-function controls (avatars, status dots, switches, sliders, progress bars, `IconButton`)
884
- stay full (999); a small square toggle (`Checkbox`, 24px) keeps a proportional small radius (6 —
885
- the same fraction of its box that 10 is of a 40px control), not the band radius. No capsules —
886
- `Button` has no pill `shape`.
887
- - **One view-control vocabulary** (all 40px, `sm` labels, in one wrapping band):
888
- · `SearchInput` is THE search box — a white `TextInputField` preset (the shared `CONTROL_RADIUS`
889
- like every other input, a thin zinc-300 border, the leading search glyph + a clear ✕). It reads as search by its
890
- ICON + white fill, not a distinct shape, so a toolbar of controls stays one consistent band.
891
- Never a bare `TextInputField` + search icon.
892
- · `ChipGroup` is THE one-of-N lens (≤ ~10 options the user flips between; include "All"; counts in
893
- the label). Filters ONE list to a SUBSET — including by process stage / lifecycle state.
894
- · `FilterChip` is THE secondary-dimension filter — a compact chip that opens a composed popover
895
- editor (`OptionList multi` / `RangeSlider` / `Counter` / date range). Single-select closes on
896
- pick via the `{({close}) => …}` render prop.
897
- · `ColumnFilter` is the TYPED column filter — give it a `FilterableColumn` (`text` / `number` /
898
- `select`) + a controlled `ColumnFilterValue` and it renders the right editor (contains / range /
899
- multi-select) inside a `FilterChip`, with `columnFilterToConditions` mapping the value to query
900
- predicates. Reach for it — ONE compact chip per dimension — when a register filters on SEVERAL
901
- columns (origin, destination, carrier, type…). Never hand-roll a row of bespoke chips/filters,
902
- and never use a permanently-expanded `ChipGroup` for a secondary dimension.
903
- · `Combobox` is the SEARCHABLE picker — reach
904
- for it over `ColumnFilter` when the value is ONE of a large/growing set you type to narrow (a
905
- port, a customer) rather than a short fixed list. Pass `recentOptions` (the same option list) so
906
- a click opens the full list, then typing filters. The **From → To route picker** is two of them
907
- (`icon="map-pin"`, `clearable`) with an `arrow-right` between — flight-search style.
908
- · `Tabs` switch between VIEWS/sections (different content/layout) — never a subset of one list. A tab earns a `status` dot (optional `TabOption.status`) only when its area needs attention (a blocker / missing item) — the resting state has none.
909
- `SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
910
- · **Layout**: search + secondary filters LEFT, the primary CTA RIGHT, in ONE band. A `ChipGroup`
911
- one-of-N lens is fine ONLY when the band has no `SearchInput`; once a search is present, the
912
- status filter becomes a `Select`/`FilterChip` dropdown — never a row of pills competing with the
913
- search.
914
- - **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
915
- `align="end"` — commit at the right edge, secondary to its left. Never left-flow a Save/Submit.
916
- - **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
917
- - **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like Card):
918
- compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE; `Alert` is the
919
- blocking modal; `Badge` is a one-word pill.
920
- - **One primary action per surface.** Everything else secondary/muted; destructive = `danger`
921
- styling + explicit label. (A grouped BUILDER section — line items — MAY give its own Add action
922
- `primary`; it sits at a different altitude than the form's one terminal commit.)
923
- - **Button labels carry no trailing ellipsis** ("Assign", not "Assign…"). **Button color is
924
- VALENCE/RISK, never category**: the ladder `muted < secondary < primary` is the emphasis axis,
925
- `danger` marks destructive — that's the whole axis. No "success"/green. Decision UIs put
926
- positive/negative color on the STATUS (dot) and verdict (colored `Text`), not the buttons.
927
- - **Color discipline** — every status / data-viz / accent color comes from a NAMED helper, never a
928
- hand-picked `colors.<family>[shade]`: `solid(name)` = the 500 (dots, series, meter accents);
929
- `tint(name, α)` = a wash; `ramp(name, count)` = N shades of ONE family for a coherent dimension.
930
- A status is ONE family NAME (`{ color: "emerald" }`) and every weight derives from it. One accent
931
- per screen purpose (blue=pipeline, emerald=money, red=danger, amber=waiting). Direct `colors.*` is
932
- reserved for NEUTRALS (`zinc`, `border`, `white`, the `*[50]` selection washes).
933
- **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
934
- `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
935
- (emerald-700) — AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
936
- (rose vs orange) stays on `Badge`s. Links use `Link`/`TextLink` (blue-600), never `solid("blue")`.
937
- - **Status indicators have a WEIGHT — match to prominence, never to a metric.** colored `Text` (a
938
- verdict WORD in a header) < `Badge variant="dot"` (a categorical STATE in a scannable column /
939
- legend) < `Badge` tonal pill (a record's PROMINENT status, a drawer/detail header). A register's
940
- dense rows read lighter — the row's primary status is `variant="dot"`, its drawer twin tonal. A
941
- `Badge` is never a metric value.
942
- - **Typography**: only the `Text` primitive (size/weight/color/transform). Uppercase tracking is
943
- built in.
944
- - **Touch & whitespace**: anything pressable is ≥ 40px tall (8px min gap between pressables). 16px
945
- between cards, 24–28 canvas padding, 16–20 inside bands, 10–12 between content lines. Density
946
- comes from alignment + hierarchy, not cramming.
947
-
948
- ---
949
-
950
- ## Worked examples (templates)
951
-
952
- Full worked-example screens ship in the package — **read the source at
953
- `node_modules/@lotics/ui/examples/<name>.tsx`** (pure `@lotics/ui` + mock data, each a usable
954
- recipe for a screen JOB; copy and adapt). Pick by the job:
955
-
956
- - **Analytics** — `tpl_dashboard` (KPIStrip + trends + attention list) · `tpl_stock`
957
- (10k-record faceted funnel) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_pivot` (the
958
- `Matrix` cross-tab desk: one dimension × another, number + heat per cell + row/col/grand totals,
959
- press a cell to drill the list behind it) · `tpl_rollup` (hierarchical totals).
960
- - **Work** — every screen is FLAT (no Card) and ONE of two shapes: a register/list you
961
- WORK EACH of (press a row → workspace `Drawer`) or a list you ACT ON MANY of (tick rows →
962
- `FloatingActionBar`) — never a side-by-side panel. `tpl_item_list` (THE canonical register and the
963
- consolidated work-list: search + a status `Select` + facet `FilterChip`s LEFT, the New CTA RIGHT in
964
- one toolbar row, a `SummaryLine` below, then a sortable, Divider-separated `Table` where every row
965
- carries a leading checkbox; a select-all band, footer `Pagination`, and a `FloatingActionBar` for
966
- the bulk action over the ticked rows; a per-row PRINT button (the trailing action column) is the
967
- row's primary action and the ⋯ its overflow; a row that can't take the bulk action gets a DISABLED
968
- checkbox (here an "Awaiting docs" record — the same gating as a blocked run line) — a row press opens
969
- the PRODUCTION workspace `Drawer`: a Details `DetailTable` showing every inline field type
970
- (text · `InlineMemberSelect` · dates incl. optional-time · dot-select · money number · the
971
- `InlineTagSelect` tag field · `InlineStatic` + System badge), a LINKED CASES section (the
972
- customer's other records as `ListItem`s — the aligned row compound (title + description at
973
- the column edge, its own 8px outdent; badge · fee · chevron right) — pressing one PUSHES an
974
- EDITABLE workspace for that record inside the drawer via the hosted `ScreenRouter`; the
975
- drawer HEADER swaps to back + the pushed id, back pops with scroll preserved), a Files section with its
976
- OWN Add CTA (`FileRows` CRUD via `pickFiles` + an EXPECTED document as a ghost `FileRow
977
- placeholder` with a Request action), the Payment `Ledger` (peekable fee rows + the Record-payment popover), an ACTIVITY section (the CRM touch-log shape: OUTCOME PILLS — `ChipGroup`, optional, tap-to-deselect — over a MULTILINE note (the note IS the record and the one requirement), an always-visible optional Follow-up `InlineDatePicker`, an "Attach files" secondary (`pickFiles` → removable `FileThumbnailGrid` in the composer), the PRIMARY Log saying WHY when disabled; below, a labelled "History" `Timeline` of outcome-typed items, newest first — an entry's attachments ride its expandable `details` as a thumbnail grid whose press opens the shared `FileGalleryModal`), and a closing `DangerZone`. Detail rows show trailing CTAs where earned (Phone → Call, Due → Today) — the case where `trailingWidth` IS set. This ONE template subsumes the old approvals / dispatch / batch / run
978
- screens: register, per-row action, gated selection, and act-on-many in one) · `tpl_pick` (guided `ScanField` run — a single-focus task column with
979
- the pick path collapsed into an `Accordion` below) · `tpl_allocate` (`RemainderMeter` split — a
980
- source/remainder summary header over the invoice list + Apply) ·
981
- `tpl_record` (THE record surface, create-then-refine: "New" = a small
982
- dialog with the identity essentials + find-or-create → lands as a Draft; the surface IS the
983
- editor — `RecordSummary` header over its key-fact `DetailTable` + a Details `DetailTable`
984
- (one shared column grid, auto-stacking when narrow), a CUSTOMER section (TWO states, no
985
- swap mode: attached = the read-only-first card — Edit/Done live in the SECTION HEADING and
986
- swap the fields to inline chips, where a trailing Fetch fills contact + city from the
987
- tax-ID registry, un-gating billing; Remove (danger) sits low and detaches the link → the
988
- find-or-create search, whose custom row opens the create Dialog rendering the SAME
989
- inline-chip table + Fetch), a FILES section (dropzone add, gallery preview, delete),
990
- and a BILLING
991
- section OWNED BY Accounting (gated until the record reaches that stage) in the INLINE vocabulary (each invoice a hairline-set band: charge amounts as chips
992
- with the list price as ghost placeholder + a one-tap "Standard …" suggestion pill while
993
- unset, "How paid…" select chips, NO per-band totals — the collect band owns the number; the
994
- band's action row sits BOTTOM-RIGHT as hint → status dot → Issue, the hint naming exactly
995
- what blocks (fees → method → the customer gate); an ISSUED invoice keeps its lines editable
996
- and offers Re-issue — a new lookup code replaces the old, confirmed in the same Dialog;
997
- validated receipt; separate refundable deposit) — and
998
- the lifecycle is a HANDOFF CHAIN (Sales → Operations → Accounting → Closed): a TASKS section
999
- speaking the FULL Task-list grammar — a clearable Group-by `FilterChip` (Desk/Assignee/Status)
1000
- + Assignee/Status filter chips derive the groups (empty groups drop; desk heads keep their
1001
- owner dots, assignee heads are `MemberChip`s), the add-a-task capture at the TOP landing on
1002
- the current desk, rows with per-task `InlineMemberSelect` assignees — EVERY desk's rows edit
1003
- (the stage gates the handoff and Billing, never task editing; planning ahead on a later desk
1004
- is normal work) — OWNER dot tags per section, per-stage handoff CTAs NEVER blocked (open
1005
- tasks warn + carry over); "New" is ONE CLICK → a fresh record at Sales; ALSO the settings shape: Preferences switch rows + `DangerZone`; a LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`)
1006
- jumps between the sections. Absorbed the
1007
- old order-form, inline-record, intake, settings, billing, and quick-capture templates) ·
1008
- `tpl_tasks` (the QUICK LIST — Apple-Reminders shape for personal lists: `CheckCircle` rows you tick +
1009
- expand in place to edit (`Inline*`, stable row — the meta summary never shifts on expand), colour-dot
1010
- tags, an attachments field (`FileThumbnailGrid` → tap a tile to the gallery for download + confirmed
1011
- remove + an `Add attachment` `pickFiles` button), search + a clearable group-by (✕ → ungrouped) + tag
1012
- filter chips, and a primary Draft-from-notes CTA `Composer → AgentRun → ChangeReview`) ·
1013
- `tpl_task_board` (the COLUMNS shape: a search · group-by · filter toolbar over a grouped, sortable
1014
- grid of inline-editable cells — assignee/due/status/tags set directly, a files cell (thumbnail glance →
1015
- popover grid → gallery), a **dynamic action column** (a per-row underlined action LINK driven by each
1016
- task's `action` descriptor — attach/approve/open, each wired in a real app to its OWN workflow: one
1017
- board, a different action per row; the link's pressable fills the cell to match the inline editors),
1018
- a trailing **`ActionMenu`** (the ⋯ overflow — a `danger` Remove + confirm; CUSTOM pressable
1019
- cells (files, the action link) hover with the control-surface BORDER reveal (`HOVER_BORDER`),
1020
- the same language as the inline-editor cells beside them — never their own background wash
1021
- today, more row actions later), per-group add rows that pre-set the group's field, and the same
1022
- Draft-from-notes CTA). Both `tpl_tasks` and `tpl_task_board`: filters/search left, primary CTA right, one
1023
- row; group-by holds only real dimensions, ✕ clears to ungrouped (no "Nothing" option). **`tpl_task_board` is the inline-managed
1024
- grouped table — MODERATE data you manage in view (group/sort/filter/edit-in-place, renders all
1025
- rows). For thousands+ you BROWSE, that's `tpl_item_list`'s paginated register, not this.** It builds
1026
- on the `DataGrid` primitive (the inline-managed grouped-table shape); it owns
1027
- only its data, toolbar, columns and per-group add. There is NO Task component — a task ROW varies
1028
- too much to bake into one, so rows compose `CheckCircle` + `InlineTextInput` + inline-editor cells
1029
- as **sibling** controls in a plain `View`. NEVER wrap the whole row in a `Pressable accessibilityRole="button"`
1030
- (for tap-to-expand) with those controls nested inside: RNW renders the row AND each control as a `<button>`,
1031
- so you get `<button>`s inside a `<button>` — invalid DOM + the inner controls drop out of the keyboard tab
1032
- order. Give "expand" its own affordance (a trailing `Pressable` over the meta/chevron), as `tpl_tasks` does.
1033
- - **Finance** — `tpl_statements` (the income statement, balance sheet, and cash flow statement
1034
- in ONE statement grammar: right-aligned column captions over fixed money columns, items
1035
- indented under group headers, a hairline rule above every subtotal, the grand total
1036
- DOUBLE-RULED, negatives in accounting parentheses, per-cell currency-free (the meta line says
1037
- VND once), no bars/charts. The three statements TIE — net income → retained earnings, closing
1038
- cash = the balance sheet's cash, the loan repayment moves the debt line — keep mock books
1039
- reconciled or the template teaches the wrong thing) · `tpl_report` (scope-first lookup report —
1040
- a header period + one-of-N dimension search drives KPIs → aligned facets w/ a `maxRows`
1041
- long-tail toggle → paginated register → export).
1042
- - **Scheduling** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
1043
- - **Agents** — *produce*: `tpl_dieline` (the design CANVAS: photo → stream → the dieline
1044
- reveals CENTRED on a pannable/zoomable surface, the floating composer morphing into `AgentProgress`,
1045
- a pinned live-edit params panel centre-right; prompt OR edit a param to iterate) ·
1046
- *Structure*: `tpl_lookup` (the ANSWER desk: converse left,
1047
- a pinned verdict panel right; a follow-up refines it) ·
1048
- `tpl_documents` (the DOCUMENT DESK — files on a record → the Use-AI fork: extract = the open
1049
- `ChangeFields` review (adds/updates/removal/conflict) + proposed order lines as `ChangeRecord`
1050
- cards, Keep-all + one outcome-named Apply; cross-check = display-only `Change` findings → a
1051
- recorded verdict; plus the Create-documents readiness checklist).
1052
-
1053
- ---
1054
-
1055
- ## Full component inventory (import as `@lotics/ui/<module>`)
1056
-
1057
- text ·
1058
- markdown (Markdown — the single canonical markdown renderer for chat, apps, and `AgentRun`; rich on web via react-markdown/remark-gfm with copyable tables, plain-text on native; takes a markdown `children` string; import `@lotics/ui/markdown.css` once for styling) ·
1059
- card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
1060
- section_heading (Section · SectionHeading · SectionHeadingTitle · SectionHeadingMeta · Subsection · SubsectionHeading · SubsectionHeadingTitle — the card-less twin of the Card family, compound, owns no margin; spacing via the Section gap (12, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold; `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title, same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3 title of a named group inside a section — heading-row siblings ride its right edge; the heading ramp is FIXED: # xxl / ## xl / ### lg, no size props) ·
1061
- section_stack (SectionStack · SubsectionStack — divided stacks that own the between-block law: a fixed beat + hairline Divider BETWEEN blocks, skipping null children (56 for the flat page's Sections, 24 for a section's Subsections); stop hand-rolling gap + `<Divider />` pairs) · badge ·
1062
- option_badge (OptionBadge — a select value as its configured colored badge) ·
1063
- member_chip (MemberChip — avatar + name; the universal person render) ·
1064
- member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
1065
- status_badge · button · icon_button · link · text_link (TextLink — underlined text that's optionally an `onPress` action or an `href` link, or plain underlined text to wrap in your own pressable; the neutral counterpart to the fixed-blue `Link`) · chip (Chip — the generic pill: pressable when `onPress` (announces as a button; pass `accessibilityLabel` when children aren't self-describing text) + an absolutely-positioned dismiss ✕ sibling when `onDismiss` (its name = `dismissTooltip` ?? the `chip.remove` locale slice). Suggestion pills → `SuggestionChip`) · tabs (Tabs — switch between content sections; WAI-ARIA tablist + roving tabindex; each TabOption takes an optional `status` ColorName → a small attention dot before its label, for a tab whose area needs work) · segmented_control ·
1066
- 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 (OptionList — the 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 (COMPOUND single-select editable search: `Combobox` root + `ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`, `useCombobox()`; over the shared `useOptionList` engine; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
1067
- text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
1068
- radio_picker · counter · range_slider · date_picker · date_range_filter_field · date_calendar (Calendar — the bare month grid: `mode="single"` or `"range"` ({start,end} — two months side by side on desktop), month/year pickers + arrows, localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday), `ref.navigateToMonth`. The engine DatePicker/DateFilter wrap in field chrome — reach for it bare only when the calendar lives permanently on the surface, not behind a field) · form_switch (FormSwitch — the FormField-labeled twin of `Switch`: toggle + clickable `label` (pressing it flips the value) + optional `description`/`error`; the settings-form boolean row. A bare toggle in a cell/toolbar → `Switch`; a full-row menu toggle → `SwitchButton`) · form_text_input (FormTextInput — `FormField` (label / description / error / optional) wrapping a `TextInputField`; the one-line labeled text field. Controls without a Form* twin just wrap in `FormField`) · switch_button (SwitchButton — the full-ROW toggle: a PressableHighlight row (optional icon + medium title left, `Switch` pinned right) where the whole row IS the switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu row toggle; vs `Switch` (bare control) and `FormSwitch` (form stack)) · use_form (useForm — THE batch draft-form state hook: `values` = `initialValues` + an edits overlay (a revalidation refreshes untouched fields, no sync effect), `validate` (sync/async, gates submit, editing clears the field's error), `onSubmit(values, helpers)` with a re-entry-guarded `submitting`, and `changes`/`changed` — the touched-fields diff that feeds DIFF-writes (send only edited fields); `setFieldValue` (curried or direct) + `setValues`/`setFieldError`/`reset`. Pairs with the Form* family for dialog/settings forms — the draft-validate-COMMIT-together twin of the self-persisting Inline* editors) · time_picker ·
1069
- inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
1070
- inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
1071
- `InlineEditView`; `InlineSelect`/`InlineMemberSelect` render the resting value like its option — `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not just text) ·
1072
- inline_static (InlineStatic — a READ-ONLY value matching the Inline* box metrics EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column; non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number column, `weight="medium"` to emphasise a total among plain rows) ·
1073
- record_summary (RecordSummary — the identity band of a record detail/drawer: ONE row — `title` xl semibold tabular · `subtitle` sm muted · `status` Badge slot · optional `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS never live in the header: compose them as `DetailTable`s in the sections below, the header's key facts as their own `DetailTable` right under the band on the same labelWidth/trailingWidth — `tpl_record` does this. Replaces hand-rolled record headers (mixed scales, several competing figures, color noise)) ·
1074
- list · list_item · menu_button · menu_list_item · detail_row (DetailTable + DetailRow — the record field grid. DetailRow: label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column FILLS the row so a stack of inline editors all span the same width + none jumps wider on edit; optional `trailing` slot renders a right-side action/badge after the value (units belong IN the value via `InlineNumberInput format`). DetailTable: the compound parent of a row STACK — `labelWidth`/`trailingWidth`/`minHeight` (default 40, the inline-control grid) declared ONCE + the 6px row gap; with `trailingWidth` every row reserves the trailing column so value cells share one width and trailing items align at one x, like a table. RESPONSIVE with no prop: it measures its own container (onLayout, not the viewport — works inside a Drawer; the unmeasured first frame renders opacity-0 so the first PAINT is already in the right mode — no reshuffle as a drawer opens) and when the columns would crush the value cell it STACKS every row (the label above a full-width value row IN THE FORMFIELD LABEL GRAMMAR — medium, default ink — so narrow record surfaces and forms read as one vocabulary; trailing beside the value, gap 14. The components stay separate: FormField = draft controls validated + committed together; inline editors self-persist); raise `minValueWidth` (default 160) when a cell holds MORE than one editor (an amount+method pair) so the table stacks earlier. Two tables on one page share one grid by repeating the same labelWidth/trailingWidth. Worked example: `tpl_record`) · ledger (Ledger + LedgerGroup + LedgerRow + LedgerTotal — the record-level money list: charge/receipt GROUPS with sums in their headers, every figure on ONE right-aligned tabular column (all lines share the 8px inset), `peek` turns a row into a pressable door floating its particulars in an anchored popover (put links INSIDE the peek — never a button in a button; `reference` is the trailing-link alternative for static rows), LedgerTotal = the divider-set emphasized close with `zeroLabel` for settled. The financial-statement grammar at record density; worked example: `tpl_item_list` drawer) · danger_zone (DangerZone — the destructive section: a soft danger-tinted frame (`tint`/`solid`, never raw hex) + a danger heading + a description + a destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a record/settings surface) · pressable_row ·
1075
- 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) · checklist (Checklist + ChecklistRow — the record-scoped checklist COMPOUND: it owns GEOMETRY only (row minHeight 32, gap 12, ring/title alignment, ONE `trailingWidth` so assignee cells column-align) while content stays composed — `control` takes the CheckCircle (omit onChange = read-only ring), children the struck transparent InlineTextInput, `trailing` an InlineMemberSelect, `menu` the row's ⋯ options ({items: ActionMenuItem[], accessibilityLabel}) — Delete lives BEHIND the menu, danger-styled and last, never a bare ✕ (omit on read-only rows). NARROW surfaces (a drawer/peek checklist) put the editors on the `meta` line instead of `trailing` — the second line indents past the ring so the TITLE keeps the full width and stays readable; wide surfaces use `trailing`; never both. SUGGESTIONS are never rows: offer the commons as `SuggestionChip`s under the list (tap = materialize, ✕ = dismiss; a pill can't be mistaken for a task). Close the list with `CaptureRow`. There is deliberately NO monolithic Task component — richer task-management rows (tpl_tasks expand affordances, board cards) compose their own anatomy directly) · suggestion_chip (SuggestionChip — the dismissible SUGGESTION pill: an item the record could have but doesn't yet (a common task, an expected line) as a `Chip` whose press MATERIALIZES it (plus glyph + label, one tuned anatomy) and whose ✕ refuses it; filter out labels already present; suggestions never count in totals. Chrome via the `suggestionChip` locale slice) · capture_row (CaptureRow — the add-an-item row closing an editable list: dashed empty ring + borderless input on the item-title inset + a primary Save that appears on type; Enter commits too. Controlled: `value`/`onChangeText`/`onSubmit`. One shape wherever a list grows in place — task checklists, simple item lists) · action_menu ·
1076
- floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
1077
- columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
1078
- 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. Example: `tpl_task_board`) · 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`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
1079
- step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
1080
- alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
1081
- CalloutText · CalloutActions) · kpi_card · kpi_strip · summary_line (SummaryLine — the light inline summary of a register/list's FILTERED view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band) · metric · trend_chip · sparkline ·
1082
- bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
1083
- funnel (Funnel — conversion funnel: narrowing bars + the step rate as a bold aligned headline row, count below; `orientation` vertical|horizontal, `onSelect`/`selectedKey` press-to-drill; the subset/drop-off sibling of stacked_progress_bar's whole-split) ·
1084
- status_grid (StatusGrid + StatusLegend) · heatmap (density cross-tab — colour-only, no numbers) · matrix (Matrix — the PIVOT cross-tab: band-compound `Matrix` root + `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) + `Matrix.Legend`; the data layer `matrixTotals`/`MatrixAxisItem`/`MatrixCellRef` import from `@lotics/ui/matrix_totals`. Pick over `Heatmap` when the NUMBER and totals matter, not just where it clusters) · legend_item · remainder_meter · allocation_row ·
1085
- 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; the per-tile ✕ is select-mode-only, never in the default view; `selectTileRemove={false}` drops even that so delete is menu-only) + built-in gallery (Download + inline preview; no "open in new tab") + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `selectTileRemove`, `labels`, `galleryLabels`, `gridMaxHeight` — cap the grid height so it scrolls and the toolbar pins, for a popover/drawer); 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 (FilePreview — the universal inline preview: image/PDF/video/audio + Word via `@lotics/docx` + Excel/CSV via `@lotics/xlsx`; the heavy engines (pdf.js · `@lotics/docx` · `@lotics/xlsx`) are LAZY (dynamic-imported, ~free until a doc of that type is opened) and SHIP AS `@lotics/ui` deps (7.14.0+) — custom-code apps get PDF/Word/Excel preview with ZERO extra install. Renders to canvas/DOM, never a nested iframe — works in the sandboxed app iframe) ·
1086
- app_icon (AppIcon — the app's launcher tile: a brand-gradient square from a `themeColor` palette token (unknown → neutral zinc) holding any Lucide icon by runtime name via DynamicIcon; `size` sm|md. One render wherever an app shows — launcher, list, picker, settings) · dynamic_icon (DynamicIcon — any Lucide icon by RUNTIME name (kebab-case string), for user/config-chosen icons a compile-time `IconName` can't express; web lazy-loads per icon, unknown name → blank. Decorative — the enclosing control carries the accessible name. A fixed, code-chosen glyph → `Icon`) · group_avatar (GroupAvatar — the first letter of `name` in a zinc rounded square (`size`, default 40); the avatar for image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`) · rotatable_image (RotatableImage — an image rotating in 90° steps that REFITS: at a quarter turn the box lays out in SWAPPED dimensions then rotates into place, so a rotated landscape fills the frame; the VIEW half of image rotation — `rotate_image` bakes the pixels for re-upload; FilePreview/ImageGallery compose it) · file_preview_types (PreviewLabels / FilePreviewProps / GalleryLabels — the shared label + prop contracts of the file-preview family; types only) · file_gallery_modal · image_gallery · use_selection (useSelection — always-on multi-select state for a register/list: the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`; selectability gating stays with the caller. The checkbox-always-visible counterpart to `use_selection_mode`) · use_selection_mode · use_section_nav (useSectionNav — scroll-spy for a LONG record surface with a left outline rail: keys in page order → {scrollRef, onScroll, register(key)→onLayout, jumpTo(key), activeKey}; rail items are `MenuButton`s (selected={activeKey===key}); section wrappers must be DIRECT children of the ScrollView content. On NARROW containers the rail becomes a PINNED bar naming the CURRENT section (the spy keeps it honest) that opens a full-page section-picker `Modal` (`MenuButton` list; Escape / the close control dismiss) — never a horizontal tab strip the thumb has to scroll. Worked example: `tpl_record`) · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
1087
- spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
1088
- comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
1089
- expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
1090
- composer: a compact pill that expands for long text + attachments) · confidence (calibrated high/med/low) ·
1091
- change_review (the COMPOUND review family — frame: ChangeReview provider/stack · ChangeReviewHeader
1092
- (auto kept-counter over decidable entries) · ChangeReviewActions (the commit bar in the
1093
- DialogFooter/DrawerFooter: Keep-all bottom-left (onAcceptAll for host-held field state) + Apply
1094
- gating); sections: Change (host-owned status, labeled verbs, collapses to its ChangeSummary + Undo;
1095
- no callbacks = display-only) · ChangeLabel · ChangeSummary · ChangeReasoning (the quiet why);
1096
- grammar: ChangeFields (the open record form) + ChangeField (THE field: − band · value · candidates
1097
- + type-another-value · reasoning · per-field Keep/Drop · collapse) · ChangeRecord (THE item card:
1098
- registers like a Change; tone wash + localized op word; verb level follows the decision level) ·
1099
- ChangeBand (the raw ± band) · ChangeValueInput (the diff-at-rest editor); the `changeReview`
1100
- locale slice) ·
1101
- clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
1102
- the agent's quick-reply surface) · sources (provenance chips, per-kind glyphs) · finding (Finding — one ranked AI-check insight: severity word · title · detail · Sources ·
1103
- children slot — with FindingComparison, the expected-vs-actual body: labeled sides + emphasized
1104
- delta; the `finding` locale slice) ·
1105
- format_money · format_date · colors (solid · tint · ramp · ColorName ·
1106
- isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback) ·
1107
- auto_sizer (AutoSizer — measures its own box via onLayout and renders the render-prop child only once `{width,height}` exist; `autoSizeWidthOnly`/`autoSizeHeightOnly`, `onResize`. For content needing pixel dimensions before first paint — a canvas, a virtualized grid) · separator (Separator — a `Divider` pre-wrapped in vertical padding (`padding: SpacerSize`, default 8): the between-groups rule WITH breathing room — what `Stack useSeparator` inserts and what menus/popovers put between option groups; `Divider` is the bare hairline) · back_button (BackButton — the chevron-left `IconButton` (lg, secondary) heading a screen/panel: `onPress` + translated `accessibilityLabel` (default "Back"); the one go-back glyph — don't hand-roll it) · info_popover (InfoPopover — the ⓘ button opening a 280px popover of explanatory `text`; the middle ground between Tooltip (short hover label) and composing Popover (rich content) — it is what `SectionHeadingTitle.info`/`CardHeaderTitle.info` render) · shortcut_badge (ShortcutBadge — the keycap hint pill: a zinc-50 badge rendering a shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on small screens. `TextInputField shortcut` renders it built-in) · trend_footer (TrendFooter — the "Up X% vs last period" caption under a chart card: signed `value` (0 = flat), `periodLabel`, optional `detail`; `goodDirection="down"` flips green/red for metrics where down is good; SKIP it when there's no comparator. Direction words via the `trendFooter` locale slice; goes in `SectionCard footer`) · dots_indicator (DotsIndicator — three looping bouncing dots (`size`/`color`); the indeterminate "working/typing" pulse `Loading` composes; use bare beside a caption while an agent thinks. Determinate work → ProgressBar) · scroll_to_bottom (ScrollToBottom — the floating jump-to-latest circle button for a chat/feed; ONLY the affordance — the caller owns positioning, visibility, and the actual scroll) · animation_fade_in (AnimationFadeIn — the mount transition: children fade (+ optional `translateY` rise) into place on first render, once; the entrance polish Accordion/Timeline/Stepper/AgentRun rows use) · landmark (Landmark — the semantic region wrapper: `kind` banner|navigation|main|complementary|contentinfo|region maps to the matching HTML element on web for screen-reader landmark navigation, `accessibilityRole` on native; `accessibilityLabel` required for `region`) · skip_link (SkipLink — the a11y bypass link: parked off-screen until keyboard focus slides it in; `href="#targetId"` jumps past repeated nav into the main region. One per app shell, FIRST in the tree; web-only) · use_async_fn (useAsyncFn — wrap an async function into a manual-trigger mutation: `[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the pending-state engine for a submit/download/upload action) · use_hover (useHover — pointer-hover state `{hovered, hoverProps}` for raw inputs/DOM controls that lack a hovered style state; native stays false. Pressable-based controls use the built-in `hovered` state instead) · use_auto_grow_height (useAutoGrowHeight — the grows-with-content textarea engine ({minLines,maxLines} → container height + `scrollEnabled` at the cap); powers Composer and multiline TextInputField — reach for it only when hand-rolling a growing input) · overlay_scope (isOverlayScopeActive / useOverlayScope — the module-level open-overlay counter every overlay primitive reports into; the host's shortcut registry reads it synchronously to floor page-level shortcuts while any overlay is open. Lives in the primitives — never call it from a screen) · route_matching (pure `:param` route-pattern utilities — routeMatches / parseRouteParams / findBestPattern (exact beats parameterized); the matching core under ScreenRouter/Dialog) · text_utils (text/typography plumbing: `getTextColor` (the TextColor→hex map incl. the AA-cleared valence set), the Inter `fontFamily*` stacks, and `getInputTextStyle`/`getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS auto-zoom; only for hand-rolled raw inputs) · use_focus_ring (useFocusRing — keyboard-aware focus state for painting a control's own ring; see Focus rings) ·
1108
- focus_ring_pressable (FocusRingPressable — a Pressable that rings on keyboard focus; the raw-control default) ·
1109
- control_surface (CONTROL_HEIGHT · CONTROL_RADIUS · FOCUS_RING · chipSurfaceStyle — the shared control-surface tokens).
11
+ The component kit for Lotics custom-code apps and the product frontend: React-Native-Web
12
+ primitives (renders on web **and** native), data-entry patterns, AI surfaces, a composition
13
+ grammar, and worked-example screens. It pairs with **`@lotics/app-sdk`** (data + RPC read
14
+ `node_modules/@lotics/app-sdk/AGENTS.md`): the SDK fetches/mutates, this kit draws.
15
+
16
+ This file is the index. The comprehensive references live in **`docs/`** — read the owning area
17
+ doc before building any screen, **never from memory**. Exact props are the shipped sources
18
+ (`src/<name>.tsx`); full worked screens are `examples/tpl_*.tsx`.
19
+
20
+ ## The area references
21
+
22
+ | Doc | Read it for |
23
+ |---|---|
24
+ | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role the ONE canonical component) + every `@lotics/ui/<module>` entry point. Read before building any screen; reuse first. |
25
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments, stage gates. |
26
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
27
+ | [docs/composition.md](./docs/composition.md) | The design-language contract canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
28
+ | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import). |
29
+
30
+ ## Iron rules
31
+
32
+ - **Reuse first; the catalog can lag `src/`.** Before hand-rolling ANY capability, `ls src/` and
33
+ grep for a match a component in `src/` missing from the catalog is a doc bug to fix, not a
34
+ license to hand-roll.
35
+ - **One canonical component per data role** (member `MemberChip`, select → `OptionBadge`,
36
+ files `FilePreview` family, …) the catalog's Reach-by-role outranks neighboring code.
37
+ - **The kit's fonts/colors/icons ARE the design system** never a custom font, icon set, or
38
+ hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
39
+ - **Pure primitives only** no i18n, analytics, or domain types in `src/` (pass `labels`,
40
+ callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
41
+ - **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
42
+
43
+ ## Keeping this reference current
44
+
45
+ These docs publish to npm and are read from `node_modules` by authors who cannot see this repo.
46
+ **Codify every new component, pattern, or rule into its OWNING area doc + the index above in the
47
+ same change, and bump the package version** — a `src/` export absent from
48
+ [docs/catalog.md](./docs/catalog.md) WILL be hand-rolled by the next author. New shapes that prove
49
+ themselves graduate into `src/` + an `examples/tpl_*` + a catalog entry, so every app inherits
50
+ them. Each fact lives in exactly one doc link, never duplicate.