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