@lotics/ui 22.1.0 → 22.3.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
@@ -15,9 +15,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [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 (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
18
- | [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 (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer) |
20
- | [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. |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [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) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
22
22
 
23
23
  ## Iron rules
@@ -174,9 +174,11 @@ that tool's `output-error` part and flows like a normal call — the amber dot m
174
174
  group's header goes amber too), the reason waits in the expanded Error panel; real runs retry and
175
175
  move on, so the feed doesn't dramatize it. A BREAKING error that terminates the whole run lives
176
176
  OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
177
- pass it as the `error` prop and it renders as a terminal danger row under the transcript. Pass
178
- **`onRetry`** alongside it and a Retry action (a secondary `Button`) rides under that row so the
179
- operator can re-fire the run; omit it and the error row renders exactly as before.
177
+ pass it as the `error` prop and it terminates the feed with an error `Callout` a contained,
178
+ tinted panel rather than another step row, because the run did not advance, it stopped, and it
179
+ carries `role="alert"` so a screen reader announces it. Pass **`onRetry`** alongside it and a Retry
180
+ action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
181
+ and the panel carries the message alone.
180
182
 
181
183
  **In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
182
184
  drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
@@ -74,8 +74,23 @@ restyle a heading level per-page.
74
74
  section's lead group. Group leads INSIDE a subsection's rows are `md` medium at most. Do NOT
75
75
  hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
76
76
  to its own section-level heading just to separate it.
77
- - **Eyebrow / label**`<Text size="xs" color="muted" weight="medium">`, **sentence case, NEVER
78
- `transform="uppercase"`**: a small quiet label above or beside content an artifact tag
77
+ - **Eyebrow vs group lead a label is one or the other, and the difference is what it
78
+ LABELS.** Both are hand-written `Text`; picking by feel is how a title ends up whispering.
79
+
80
+ | | Eyebrow | Group lead |
81
+ |---|---|---|
82
+ | Labels | a VALUE — beside or above it | a BLOCK — a titled sub-part of a section |
83
+ | Treatment | `xs` muted medium | `sm`–`md` medium (never semibold — that rung is a heading) |
84
+ | Examples | a metric's caption, a field name in a cell, an artifact tag ("Proposed") | "Deciding factor" over the fact it names, "Delivery" over an address stack |
85
+
86
+ The eyebrow is calibrated for DENSE surfaces — a register row, a drawer's detail stack, a
87
+ table cell — where a 12px label sits beside dozens of siblings and quiet is the point. On a
88
+ low-density surface (a single-answer page, a public result screen) the same treatment reads as
89
+ an afterthought: there are no siblings for it to be quiet among. A label that titles a block
90
+ someone must ACT on is a group lead, and `sm medium` is its floor.
91
+
92
+ - **Eyebrow** — `<Text size="xs" color="muted" weight="medium">`, **sentence case, NEVER
93
+ `transform="uppercase"`**: a small quiet label above or beside a VALUE — an artifact tag
79
94
  ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
80
95
  label. **All-caps is banned** — it reads as shouting, and reflexively uppercasing every little
81
96
  label is the #1 thing that makes a surface feel templated. Sentence case + medium weight, full
@@ -566,6 +581,30 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
566
581
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
567
582
  hierarchy, not cramming.
568
583
 
584
+ ## Testing an overlay component — assert through the logic, prove in a browser
585
+
586
+ **A `Popover`-backed surface does not mount under jsdom.** It positions in a `useLayoutEffect` +
587
+ rAF against `getBoundingClientRect`, which jsdom answers with zeroes, so the content never renders
588
+ no matter how long you `waitFor`. That covers everything built on it: the `Combobox` listbox,
589
+ `Select`, `Menu`, `Tooltip`, `InfoPopover`, `Peek`. The kit's own suite is pure-logic for exactly
590
+ this reason — there is no render test for any of them.
591
+
592
+ The trap is that the CONTROL works fine, so the failure looks like a wiring bug: the input takes
593
+ its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere. Time goes into
594
+ debugging the component instead of the harness.
595
+
596
+ So split the assertion:
597
+
598
+ - **the decision** — whatever the selection drives (which rate is reachable, which rows survive a
599
+ filter) belongs in a pure module with its own test. That is where the consequential logic lives,
600
+ and it tests far better there: every branch, no DOM.
601
+ - **the wiring** — that picking an option calls the handler and the screen re-renders — is a
602
+ browser check (`lotics app dev` + Playwright), which the quality bar requires anyway.
603
+
604
+ A component test can still assert everything BEFORE the overlay opens: the empty default, the
605
+ placeholder, the disabled state, and — often the point — what the screen refuses to claim while
606
+ nothing is selected.
607
+
569
608
  ## Microcopy — content, not mechanics
570
609
 
571
610
  UI copy states WHAT something is — domain content or one real rule — never how the widget
@@ -592,6 +631,18 @@ that praises it is marketing.
592
631
  cut-off, the box misses the sailing").
593
632
  - **The deletion test**: if removing the line changes nothing for the reader, remove the line —
594
633
  `description` is optional for a reason; when no invisible truth is worth stating, omit it.
634
+ - **NEVER join text with a middot — ` · ` is banned.** It is punctuation doing a word's job: it
635
+ asserts that two things are related while refusing to say how, leaving the reader to guess
636
+ between "and", "of", "for", "then", and "here is another fact". It is also the single loudest
637
+ templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it, so the
638
+ only thing carrying the relationship vanishes. Use the mark that MEANS what you intend:
639
+ **`:`** for a run-in label naming its value (`Hàng hoá: <goods>`), **`( )`** for a
640
+ supplementary count or qualifier (`Đã đọc biểu thuế (2 bước)`), **`;`** between two related
641
+ clauses, **`,`** in a genuine list of like items, a real preposition when the relation is
642
+ semantic (`Tính thuế cho 8415.10.20`) — or a **line break plus ink contrast**, which is what
643
+ a middot is usually a substitute for. Facts of two different KINDS want two lines, not one
644
+ line with a glyph between them. (Verbatim quoted DATA keeps its own delimiters — this governs
645
+ copy we write, not text we reproduce.)
595
646
  - **Plain language over jargon; effect over mechanism** ("Record visibility", not "Row-Level
596
647
  Privacy") — if a term needs explaining, replace it. **One concept = one word everywhere** —
597
648
  pick the term once and reuse it on every surface.
@@ -198,6 +198,23 @@ Past two columns the label→field link breaks — MANY inputs means GROUPING in
198
198
  wrap their OWN `FormField` (and don't put a `style` on it) — for a grid cell use a bare
199
199
  `FormField style={half}` wrapping `DatePicker`/`Picker`.
200
200
 
201
+ ## Browser autofill — suppressed on search, kept on forms
202
+
203
+ **A control that owns a suggestion list must suppress the browser's.** Chrome offers saved form
204
+ values on any text input it does not recognize as opted out, and on a `Combobox` the two dropdowns
205
+ STACK — the component's own options render behind the browser's autofill panel, which reads as a
206
+ rendering bug in the app.
207
+
208
+ The kit sets `autoComplete="off"` for you on every control whose content is a query rather than a
209
+ personal detail: `ComboboxInput`, `SearchInput`, `ScanField`, `ColumnFilter`, and the shared
210
+ `OptionList` search. Nothing to pass.
211
+
212
+ It deliberately does NOT do this on `FormTextInput`, the inline editors, or the comment composer.
213
+ A contact form SHOULD autofill a name, an email and a phone number — suppressing it there is a real
214
+ usability loss, and which fields those are is the app author's call, not the kit's. Pass
215
+ `autoComplete` yourself on a form field (`"name"`, `"tel"`, `"email"`, `"off"`); it forwards to the
216
+ DOM input.
217
+
201
218
  ## Find-or-create — the `Combobox` family IS the control
202
219
 
203
220
  `Combobox` is COMPOUND: a root holds the DATA + behaviour (its internal option-list engine —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "22.1.0",
3
+ "version": "22.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/accordion.tsx CHANGED
@@ -130,7 +130,12 @@ export function AccordionTitle(props: AccordionTitleProps) {
130
130
  return (
131
131
  <View style={styles.title}>
132
132
  {props.icon ? <Icon name={props.icon} size={16} color={props.iconColor ?? colors.zinc[500]} /> : null}
133
- <Text size="sm" numberOfLines={1} style={styles.titleText}>
133
+ {/* MEDIUM, like every other row identity in the kit (`ListItem`,
134
+ `MenuButton`, `MenuListItem`, `DetailRow` label). This was the one
135
+ pressable-row title at regular weight, so in a long register — 200
136
+ instruments, say — the thing you scan and press carried no more weight
137
+ than the prose above the list. */}
138
+ <Text size="sm" weight="medium" numberOfLines={1} style={styles.titleText}>
134
139
  {props.children}
135
140
  </Text>
136
141
  </View>
package/src/agent_run.tsx CHANGED
@@ -4,6 +4,7 @@ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { Button } from "./button";
6
6
  import { Icon, type IconName } from "./icon";
7
+ import { Callout, CalloutActions, CalloutText } from "./callout";
7
8
  import { Markdown } from "./markdown";
8
9
  import { JsonPanel, stringifyData } from "./json_panel";
9
10
  import { PressableHighlight } from "./pressable_highlight";
@@ -187,27 +188,44 @@ export function AgentRun(props: AgentRunProps) {
187
188
  />
188
189
  );
189
190
  })}
190
- {/* A run-level BREAKING error terminates the feed with a danger row. It lives
191
- outside `parts` (the stream failed), so the caller passes it explicitly.
192
- A retry action rides UNDER it when the caller can restart the run. */}
191
+ {/* A run-level BREAKING error terminates the feed. It lives outside `parts`
192
+ (the stream failed), so the caller passes it explicitly.
193
+ A CONTAINED surface, not another step row: the run didn't advance, it
194
+ stopped, and a tinted panel says that at a glance where a bare red line
195
+ in the step gutter reads as one more step that happened to fail. It also
196
+ gives the retry a container — free-floating, a full-weight button in a
197
+ column of text reads as a stray control — and `Callout` carries
198
+ `role="alert"`, which the plain row did not.
199
+ FULL WIDTH, unlike `reasoning` / `rowDetail`: those indent to a step's
200
+ text column because they are that step's sub-content. This hangs under
201
+ no step — it is the whole run ending — so indenting it would make a
202
+ run-level failure read as the last step's detail. */}
193
203
  {props.error ? (
194
- <View style={{ gap: 8 }}>
195
- <View style={styles.row}>
196
- <View style={styles.dotCol}>
197
- <Icon name="circle-alert" size={17} color={colors.red[500]} />
198
- </View>
199
- <View style={styles.rowBody}>
200
- <Text size="sm" color="danger">
201
- {props.error}
202
- </Text>
203
- </View>
204
- </View>
204
+ <Callout tone="error">
205
+ <CalloutText>{props.error}</CalloutText>
205
206
  {onRetry ? (
206
- <View style={styles.retry}>
207
- <Button title={locale.agentRun.retry} color="secondary" icon="rotate-ccw" alignSelf="flex-start" onPress={onRetry} />
208
- </View>
207
+ <CalloutActions>
208
+ {/* PRIMARY, deliberately. `Callout` is a calm panel by design (a
209
+ 50 tint behind a soft 100 border), so a filled button inside it
210
+ is not emphasis twice — it is the one action on a quiet
211
+ surface. Every unfilled variant loses here: default and muted
212
+ hover to zinc-100, a grey wash over the red tint, and
213
+ danger-secondary puts red ink on a red-50 ground, the weakest
214
+ contrast of the options. The run just died; the only way out
215
+ should be the most legible thing in the panel.
216
+ Still a Button, never a link: a link navigates, this re-fires a
217
+ run, and that distinction is what a screen reader and the
218
+ keyboard both rely on. */}
219
+ <Button
220
+ title={locale.agentRun.retry}
221
+ color="primary"
222
+ icon="rotate-ccw"
223
+ alignSelf="flex-start"
224
+ onPress={onRetry}
225
+ />
226
+ </CalloutActions>
209
227
  ) : null}
210
- </View>
228
+ </Callout>
211
229
  ) : null}
212
230
  </View>
213
231
  );
@@ -365,10 +383,14 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
365
383
 
366
384
  function SummaryLabel({ label, count, stepsLabel }: { label: string; count: number; stepsLabel: (n: number) => string }) {
367
385
  return (
386
+ // PARENTHESES, not a middot. The count is supplementary to the action, and
387
+ // parentheses say so in a mark that survives text extraction and screen
388
+ // readers; a middot asserts a relationship without naming it, and the ink
389
+ // contrast here was already doing the separating anyway.
368
390
  <Text size="sm" weight="medium" numberOfLines={1}>
369
391
  {label}
370
392
  <Text size="sm" color="muted">
371
- {" · " + stepsLabel(count)}
393
+ {" (" + stepsLabel(count) + ")"}
372
394
  </Text>
373
395
  </Text>
374
396
  );
@@ -482,7 +504,4 @@ const styles = StyleSheet.create({
482
504
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
483
505
  // A step's expanded I/O panels — same left edge as the reasoning body.
484
506
  rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
485
- // The retry action under the terminal error — aligned to the message body
486
- // (past the dot column + its gap), so it reads as a response to that row.
487
- retry: { paddingLeft: 28 },
488
507
  });
@@ -118,6 +118,9 @@ export function ColumnFilter(props: ColumnFilterProps) {
118
118
  >
119
119
  {column.type === "text" ? (
120
120
  <TextInputField
121
+ // A filter value is never a saved personal detail, so the browser
122
+ // has nothing useful to offer and a dropdown to obscure the rows with.
123
+ autoComplete="off"
121
124
  autoFocus
122
125
  value={value?.kind === "text" ? value.query : ""}
123
126
  onChangeText={(query) => onChange({ kind: "text", query })}
package/src/combobox.tsx CHANGED
@@ -389,6 +389,11 @@ export function ComboboxInput(props: ComboboxInputProps) {
389
389
  editable={!ctx.disabled}
390
390
  autoFocus={ctx.autoFocus}
391
391
  autoCapitalize="none"
392
+ // The browser must not open its OWN suggestion list over this one.
393
+ // Chrome offers saved form values on any unmarked text input, and on a
394
+ // control that already owns a listbox the two dropdowns stack — the
395
+ // component's options end up behind the browser's autofill panel.
396
+ autoComplete="off"
392
397
  autoCorrect={false}
393
398
  accessibilityLabel={accessibilityLabel}
394
399
  style={[
@@ -213,7 +213,7 @@ function CommentRow(props: CommentRowProps) {
213
213
  <Text weight="medium">{displayName}</Text>
214
214
  <Text size="sm" color="zinc-500">
215
215
  {formatTimestamp(comment.created_at)}
216
- {comment.updated_at !== comment.created_at ? ` · ${labels.edited}` : ""}
216
+ {comment.updated_at !== comment.created_at ? ` (${labels.edited})` : ""}
217
217
  </Text>
218
218
  <Spacer size={8} />
219
219
  {isEditing ? (
@@ -68,6 +68,11 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
68
68
  <View testID={testID} style={[styles.container, small ? { flex: 1 } : { maxHeight: 480 }]}>
69
69
  {search.mode === "internal" ? (
70
70
  <TextInputField
71
+ // The browser must not open its OWN suggestion list over this one.
72
+ // Chrome offers saved form values on any unmarked text input, and on a
73
+ // control that already owns a listbox the two dropdowns stack — the
74
+ // component's options end up behind the browser's autofill panel.
75
+ autoComplete="off"
71
76
  icon="search"
72
77
  value={list.query}
73
78
  onChangeText={list.setQuery}
@@ -37,6 +37,11 @@ export function ScanField(props: ScanFieldProps) {
37
37
  return (
38
38
  <View style={{ gap: 6 }}>
39
39
  <TextInputField
40
+ // The browser must not open its OWN suggestion list over a scan target.
41
+ // Chrome offers saved form values on any unmarked text input, and on a
42
+ // control that already owns a listbox the two dropdowns stack — the
43
+ // component's options end up behind the browser's autofill panel.
44
+ autoComplete="off"
40
45
  value={value}
41
46
  onChangeText={onChangeText}
42
47
  onSubmitEditing={onScan}
@@ -46,6 +46,11 @@ export function SearchInput(props: SearchInputProps) {
46
46
  <View style={style as ViewStyle}>
47
47
  <TextInputField
48
48
  {...rest}
49
+ // The browser must not open its OWN suggestion list over this one.
50
+ // Chrome offers saved form values on any unmarked text input, and on a
51
+ // control that already owns a listbox the two dropdowns stack — the
52
+ // component's options end up behind the browser's autofill panel.
53
+ autoComplete="off"
49
54
  ref={mergedRef}
50
55
  onKeyPress={handleKeyPress}
51
56
  icon="search"