@lotics/ui 43.6.0 → 43.7.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
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
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` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); 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 (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — 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, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), 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 — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; 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, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
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 (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every pair on ONE ROW starts at the SAME rung, a register's subject is `sm` over `xs` with values `sm`, and the subject leads by WEIGHT and its identity mark rather than by size), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
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 (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [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) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
package/docs/catalog.md CHANGED
@@ -400,7 +400,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
400
400
 
401
401
  - **`text`** — the `Text` primitive (never raw `div`/`span`/`fontSize`): `size` `xs`–`xxxl`
402
402
  (the heading ramp: `xl` = `##` section title, `xxl` = `#` page/record title, `xxxl` = hero
403
- numbers), `weight`, `color`, alignment, tabular numerals.
403
+ numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
404
+ box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
405
+ its annotation; see composition.md, which also covers the `gap` that goes with it.
404
406
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
405
407
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
406
408
  native; takes a markdown `children` string. **`variant`** decides whose type
@@ -1165,20 +1165,50 @@ wider pair then reads as though its second line shrank rather than stepped. Pick
1165
1165
  relative to the primary and the pair scales together. The probe: collect every primary/supporting
1166
1166
  pair on the screen and divide. A spread in the RATIOS is the finding, not a spread in the sizes.
1167
1167
 
1168
- **And every pair ON ONE ROW sits at the SAME rungs — a register's row subject is `sm`, its
1169
- supporting line `xs`, and a value in any other column is `sm` too.** "One rung below" is only half
1170
- a rule: it constrains the STEP and says nothing about where the pair starts, so a subject placed a
1171
- rung high stays compliant while dragging its whole column above the rest. Measured on a register
1172
- that read as disproportionate: the identity column ran 16/14 while the column beside it ran 14/12
1173
- — both correctly "one rung apart", one whole rung out of step with each other, and the first column
1174
- shouted while everything else receded.
1168
+ **Every SUBJECT on one row starts at the same rung — a register's is `sm`, and a value in any
1169
+ other column is `sm` too.** "One rung below" is only half a rule: it constrains the STEP and says
1170
+ nothing about where the pair starts, so a subject placed a rung high stays compliant while dragging
1171
+ its whole column above the rest. Measured on a register that read as disproportionate: the identity
1172
+ column ran 16/14 while the column beside it ran 14/12 — both correctly "one rung apart", one whole
1173
+ rung out of step with each other, and the first column shouted while everything else receded.
1175
1174
 
1176
1175
  **The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
1177
- plus the avatar or tile that answers "which row is this", is enough and it is what
1178
- `tpl_item_list` does. Money is the case that most often argues for a size step and should not get
1179
- one: tabular figures and right alignment already mark it, and a lone larger value on a row reads as
1180
- a mistake rather than as emphasis. One larger thing among peers is not emphasis; it is the thing
1181
- that looks broken.
1176
+ plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
1177
+ often argues for a size step and should not get one: tabular figures and right alignment already
1178
+ mark it, and a lone larger value on a row reads as a mistake rather than as emphasis. One larger
1179
+ thing among peers is not emphasis; it is the thing that looks broken.
1180
+
1181
+ **Whether the SUPPORTING line drops a rung is decided by what else separates the pair — not by
1182
+ where it sits and not by what it carries.** `color="muted"` is already one channel of demotion, so
1183
+ the size step is a second, and a second is only worth its cost where nothing else is doing the job:
1184
+
1185
+ - The subject carries `medium`, or the row opens with an identity mark — the supporting line stays
1186
+ at **`sm` muted**. Weight and the mark separate the pair; colour finishes it, and the line keeps
1187
+ body size. This matters most where that line is the DISAMBIGUATOR (which person, at which
1188
+ company): `xs` demotes content the reader needs, and 12px is where stacked diacritics crowd.
1189
+ - Both lines are regular weight and colour is the only thing between them — the supporting line
1190
+ drops to **`xs`**. Measured: a date over its touch count, promoted to one rung, stopped reading
1191
+ as a value and its annotation and started reading as two peers. The step is what makes them a
1192
+ pair.
1193
+
1194
+ Deciding from the CONTENT instead — prose gets `sm`, reference data gets `xs` — fails on a line
1195
+ that holds both. An account name beside a contract number is one line, and it takes ONE rung: the
1196
+ pair's, not each half's.
1197
+
1198
+ **A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
1199
+ leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
1200
+ object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
1201
+ a 40px mark the same fault is measurable rather than a matter of taste — the stack comes to 50 and
1202
+ overhangs the mark it belongs to. Tight is `size + 6`, which is what `xs` has always been, so this
1203
+ is the smallest rung's own rule extended up the ladder rather than a second opinion about leading.
1204
+
1205
+ Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
1206
+ tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
1207
+ line box already contributes 3px above and below, giving 6px between the two, and a `gap` on top
1208
+ is a third source of space between lines that are meant to read as one. Measured after: the
1209
+ identity stack sits at exactly the mark's 40, and every pair on the screen carries the same 6px.
1210
+
1211
+ Running text keeps prose leading. The default is for paragraphs, and `.ui-markdown` renders at it.
1182
1212
 
1183
1213
  **A CONTROL riding a text line must not resize that line.** A control is taller than the text it
1184
1214
  sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
@@ -166,8 +166,11 @@ function TuanRow({ nv }: { nv: NhanVien }) {
166
166
  <Accordion>
167
167
  <AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
168
168
  <View style={{ flex: 1, gap: 2 }}>
169
- <Text size="sm" weight="medium" numberOfLines={1}>{nv.ten}</Text>
170
- <Text size="xs" color="muted" numberOfLines={1}>{nv.chucVu}</Text>
169
+ <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{nv.ten}</Text>
170
+ {/* Body size: `medium` above already separates the pair, so the rung
171
+ down would be a second demotion on the line that says WHICH
172
+ person this is. */}
173
+ <Text size="sm" color="muted" leading="tight" numberOfLines={1}>{nv.chucVu}</Text>
171
174
  </View>
172
175
  <View style={{ flexDirection: "row", gap: 10 }}>
173
176
  {nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
@@ -503,9 +503,11 @@ export function TplDashboard() {
503
503
  <Avatar name={kh.ten} size="md" />
504
504
  <View style={{ flex: 1, gap: 0, alignItems: "flex-start" }}>
505
505
  <Peek accessibilityLabel={`Customer profile for ${kh.ten}`} content={<KhachPeek kh={kh} />}>
506
- <Text size="sm" weight="medium" numberOfLines={1}>{kh.ten}</Text>
506
+ <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{kh.ten}</Text>
507
507
  </Peek>
508
- <Text size="xs" color="muted" numberOfLines={1}>{kh.nganh}</Text>
508
+ {/* Body size the mark and the name's weight separate this
509
+ pair; see composition.md on what decides the rung. */}
510
+ <Text size="sm" color="muted" leading="tight" numberOfLines={1}>{kh.nganh}</Text>
509
511
  </View>
510
512
  <Sparkline data={kh.trend} width={84} height={24} color={kh.trend[5] >= kh.trend[0] ? solid("emerald") : solid("red")} />
511
513
  <Text size="sm" weight="medium" tabular>{formatMoney(kh.doanhThu)}</Text>
@@ -223,90 +223,36 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
223
223
  trailing={<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />}
224
224
  >
225
225
  <TableCell>
226
- {/* THE IDENTITY MARK leads the cell that names the row. A register of
227
- entities without one is N rows of grey text a reader scans linearly;
228
- with one, the eye lands on the row it wants by colour and letter
229
- before it reads a word. `Avatar` derives its hue from the name, so
230
- the marks differ across rows without anyone configuring anything —
231
- the failure mode to avoid is N identical marks, which is the largest
232
- and brightest thing on the row carrying no information at all.
233
-
234
- It sits in the SUBJECT'S CELL, not the `leading` slot: `leading`
235
- already holds the selection checkbox, and those two are different
236
- jobs — one is what the row IS, the other is something you are doing
237
- to it. Stacking them in one slot reads as a single compound control.
238
-
239
- `lg` because the customer is what this row is ABOUT. A person who is
240
- one FIELD among columns (the owner, below) takes `md`; see
241
- `avatar_size.ts`, which owns that rule.
242
-
243
- ONE SHAPE for the whole register. `Avatar` takes `square` for an
244
- organization, but that distinction earns its place only where a
245
- surface mixes entity TYPES and the reader acts on which is which.
246
- Here every row is a record, and whether the customer is a company is
247
- not something the reader does anything with — so a column of mixed
248
- circles and squares reads as an inconsistency rather than as
249
- information, which is exactly how it gets reported. */}
226
+ {/* The mark goes in the SUBJECT'S cell, not the `leading` slot
227
+ `leading` holds the selection checkbox, and "what this row is" and
228
+ "something you are doing to it" in one slot read as a single
229
+ compound control. Rung and shape per composition.md § Identity. */}
250
230
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10, alignSelf: "stretch" }}>
251
231
  <Avatar name={hs.khach} size="lg" />
252
- <View style={{ gap: 2, flex: 1, minWidth: 0 }}>
232
+ <View style={{ gap: 0, flex: 1, minWidth: 0 }}>
253
233
  {/* The name takes the row, the thread pins right. A comment count is
254
234
  the one thing on this row that is about PEOPLE talking rather than
255
235
  the record's own state, so it sits apart from the values below it
256
236
  instead of joining them on the supporting line. */}
257
237
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
258
- <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
238
+ <Text size="sm" weight="medium" leading="tight" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
259
239
  {/* Rendered only where a thread EXISTS — a zero on every quiet row is a
260
240
  column of noise that trains the eye to skip where the signal appears. */}
261
241
  {hs.binhLuan > 0 ? <CommentsButton count={hs.binhLuan} subject={hs.khach} onPress={onOpenComments} /> : null}
262
242
  </View>
263
- {/* Every value on this row is selectable that is what a register owes
264
- its reader, and it costs no chrome. The phone gets a CONTROL on top
265
- because it is the one value copied often enough to earn one (a desk
266
- pastes it into a message, a rep dials it). The verb rides the value
267
- inside its own cell: the trailing gutter belongs to the ⋯, and with
268
- two values in this cell it would have nothing to name.
269
-
270
- UNCONDITIONAL, and that is the load-bearing part of this shape. A
271
- row centres its cells, so a supporting line that appears only when
272
- its field is set makes this cell two heights, and the NAME above it
273
- then sits at two different offsets down the column while every row
274
- still measures the same. That is why the values here are ones every
275
- record carries a key, a contact rather than whichever field is
276
- most interesting: the line has to be honest on every row to be
277
- reservable at all. */}
278
- {/* HEIGHT PINNED TO THE xs LINE BOX (18), because this line carries a
279
- CONTROL. `CopyButton` at `sm` is 24 — sized to sit beside `sm`
280
- text, per its own doc — so on an `xs` line it made the row 24 and
281
- `alignItems: "center"` spent 3 of those px above the text. The
282
- supporting line then started 5px under the name while the Fee
283
- column's identical primary-over-supporting pair, which carries no
284
- control, started 2px under its own. Same shape, two treatments,
285
- and the reason (one of them has a button) is invisible to a
286
- reader — which is the whole test.
287
-
288
- Pinning the row to the text's line box lets the 24px hit area
289
- overflow it without moving the text. Any verb riding a supporting
290
- line needs this; a control must not resize the line it sits on. */}
291
- <View style={{ flexDirection: "row", alignItems: "center", gap: 2, height: 18 }}>
292
- {/* The KEY, muted, on the supporting line — at most this, never a
293
- column and never the row's name.
294
-
295
- It does NOT shrink. A key is quoted whole or it is not a key: a
296
- phone number clipped to "(555) 384-7…" is still recognisably a
297
- number the reader can widen the column to finish, while
298
- "RC-2026-0…" is unusable — it is the exact string a desk reads
299
- out. So the fixed-length identifier holds its width and the phone
300
- gives way, which is also the right order of loss: the phone has a
301
- Copy control beside it that hands over the full value regardless. */}
302
- <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
303
- {/* A GAP, not a middot. Two facts of different kinds sat either side
304
- of a ` · ` here, which is the separator this kit bans outright —
305
- punctuation claiming a relation it refuses to name, and dropped
306
- entirely by a screen reader. Spacing separates them without
307
- asserting anything, and the two are already told apart by shape:
308
- one is a key, one is a number with a Copy control on it. */}
309
- <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
243
+ {/* UNCONDITIONAL the load-bearing part of this shape. A row centres
244
+ its cells, so a supporting line that renders only when its field is
245
+ set makes this cell two heights, and the name above it then sits at
246
+ two different offsets down the column while every row still measures
247
+ the same. So the values here are ones every record carries. */}
248
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
249
+ {/* The key does NOT shrink. Quoted whole or it is not a key: a phone
250
+ clipped to "(555) 384-7…" is still a number you can widen the
251
+ column to finish, while "RC-2026-0…" is unusable. So the
252
+ fixed-length identifier holds its width and the phone gives way —
253
+ and the phone has a Copy control that hands over the full value. */}
254
+ <Text size="sm" color="muted" tabular leading="tight" numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
255
+ <Text size="sm" color="muted" tabular leading="tight" numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
310
256
  <CopyButton value={hs.dienThoai} label="Copy phone number" />
311
257
  </View>
312
258
  </View>
@@ -378,12 +324,10 @@ function RecordDrawer({ hs, daThu: _daThu, onPaid: _onPaid, onClose, onPrev, onN
378
324
  position={canGoBack ? undefined : position}
379
325
  >
380
326
  <Screen route="">
381
- {/* THE RECORD SURFACE, not a copy of it. tpl_record renders in `drawer`
382
- chrome: same sections, same order, same rules the rail and the docked
383
- discussion collapse (a Drawer has no gutters), and the thread joins the
384
- reading order as its last section. Rendering the component is what keeps
385
- the two identical; the hand-built workspace that used to live here is
386
- how they drifted — a checklist on this side, a pipeline on that one. */}
327
+ {/* THE RECORD SURFACE, not a copy of it — `tpl_record` in `drawer`
328
+ chrome: same sections, same order, same rules. The rail and the docked
329
+ discussion collapse (a Drawer has no gutters) and the thread joins the
330
+ reading order as its last section. */}
387
331
  <TplRecord chrome="drawer" code={hs.ma} openSection={openSection} />
388
332
  </Screen>
389
333
  <Screen route="/record/:ma">{(p) => <LinkedRecordScreen ma={p.ma} />}</Screen>
@@ -423,15 +367,9 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
423
367
  <DetailRow label="Fee">
424
368
  <InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
425
369
  </DetailRow>
426
- {/* FILES AS A FIELD the `Inline*` member that used to be missing,
427
- so a record's documents got hand-rolled per app as `FileRows` +
428
- `pickFiles` + a `Button`, each copy re-deriving the same
429
- decisions and usually missing `InlineSlot` (which is what keeps
430
- this value column a column rather than two left edges).
431
-
432
- The MANY case: `multiple` defaults true, the CTA becomes
433
- "Add document" once something is attached, and the host APPENDS.
434
- `tpl_record` shows the single-file case, where the verb is
370
+ {/* FILES AS A FIELD, the MANY case: `multiple` defaults true, the CTA
371
+ becomes "Add document" once something is attached, and the host
372
+ APPENDS. `tpl_record` shows the single-file case, where the verb is
435
373
  replace — see the table in data_entry.md §InlineFiles. */}
436
374
  <DetailRow label="Documents">
437
375
  <InlineFiles
@@ -1049,19 +987,14 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1049
987
  </DialogFooter>
1050
988
  ) : phase === "analyze" || phase === "clarify" || phase === "running" ? (
1051
989
  <DialogFooter>
1052
- {/* EVERY run-driven phase, not just `clarify`. This branch used to
1053
- name that one phase, because `AgentRunActions` only ever held the
1054
- wizard and there was nothing to show while the feed streamed
1055
- so the two streaming phases had an empty footer and no way to
1056
- stop. The component's contract is that the HOST mounts it
1057
- wherever the run drives the surface and the RUN decides what it
1058
- holds; gating it by phase out here duplicates that decision and
1059
- gets it wrong the moment the run learns a new one.
1060
-
1061
- `onStop` is the SAME handler the pane's `onCancel` gets: stopping
1062
- a streaming run and abandoning a parked question both put the
1063
- operator back at the intake they started from, with their files
1064
- and their answers still there to retry from. */}
990
+ {/* EVERY run-driven phase, not just `clarify`. The contract is that the
991
+ HOST mounts this wherever the run drives the surface and the RUN
992
+ decides what it holds; gating it by phase out here duplicates that
993
+ decision and gets it wrong the moment the run learns a new phase.
994
+
995
+ `onStop` is the SAME handler the pane's `onCancel` gets: stopping a
996
+ stream and abandoning a parked question both put the operator back
997
+ at the intake, files and answers intact. */}
1065
998
  <AgentRunActions run={run} onStop={() => setPhase("intake")} />
1066
999
  </DialogFooter>
1067
1000
  ) : phase === "preview" ? (
@@ -4178,8 +4178,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
4178
4178
  it on line 2, in one cell rather than two columns. */}
4179
4179
  <TableCell>
4180
4180
  <View style={{ gap: 2 }}>
4181
- <Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
4182
- <Text size="xs" color="muted" numberOfLines={1}>
4181
+ <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{f.name || "—"}</Text>
4182
+ {/* Body size: the subject carries `medium`, which is what
4183
+ makes this a pair rather than two peers. */}
4184
+ <Text size="sm" color="muted" leading="tight" numberOfLines={1}>
4183
4185
  {f.party || (f.direction === "charge" ? "Charge" : "Cost")}
4184
4186
  </Text>
4185
4187
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.6.0",
3
+ "version": "43.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/text.css CHANGED
@@ -15,25 +15,21 @@
15
15
  * single line, so every label, value and control gained ~4px of line box.
16
16
  * Density is what was traded for the rhythm.
17
17
  *
18
- * TRACKING: NEGATIVE throughout, steepening as the type grows.
18
+ * `[data-text-leading="tight"]` buys that density back where the text is a
19
+ * STACKED PAIR rather than prose — the box becomes `size + 6`, which is what
20
+ * `xs` has always been. See `TYPE_LEADING_TIGHT_*`; a rung already at or below
21
+ * that keeps its own number, so only the rungs that left it for a prose ratio
22
+ * have a rule here.
19
23
  *
20
- * The body rung is ANCHORED on -0.028em — the -0.4px this product already set on
21
- * `.ui-markdown` and on six inputs, expressed as an em so it scales. That value
22
- * is the product's own, arrived at by reading it in Vietnamese every day, and it
23
- * is what the rest of the ramp is derived FROM rather than something imported
24
- * from another design system. Inter is drawn generously at default tracking, so
25
- * untracked body reads loose beside it.
24
+ * TRACKING: Inter's own dynamic-metrics curve, evaluated per rung — the
25
+ * derivation and the function live in `type_ramp.ts`. Never nudged by eye: the
26
+ * typeface's designer has already answered this for this typeface, and a screen
27
+ * that reads too tight or too loose wants a size or a leading change instead.
28
+ * Zero at 12px, steepening as the type grows.
26
29
  *
27
- * Larger type takes proportionally more negative tracking, which is why the curve
28
- * steepens: what looks right at 14px looks slack at 32px.
29
- *
30
- * `xs` is the one rung held back (-0.02em, not the full body value). 12px is
31
- * where Vietnamese stacked diacritics (ự, ộ, ế, ệ) and Inter's `1`/`l`/`i` come
32
- * closest to colliding, and tightening is the direction that makes both worse.
33
- *
34
- * `.ui-markdown` and the inputs (`INPUT_LETTER_SPACING`) mirror the body rung
35
- * exactly. All three are ONE contract on both axes — leading above, tracking here
36
- * — and a value moved on one surface alone is the seam re-opening.
30
+ * `.ui-markdown` and the inputs (`INPUT_LETTER_SPACING`) sit on the body rung
31
+ * exactly. All three are ONE contract on both axes — leading above, tracking
32
+ * here — and a value moved on one surface alone is the seam re-opening.
37
33
  *
38
34
  * Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
39
35
  * `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
@@ -82,6 +78,20 @@
82
78
  }
83
79
 
84
80
  /* Refer to `use_screen_size` for breakpoints */
81
+ /*
82
+ * TIGHT — a stacked pair, not prose. Only the rungs whose prose leading exceeds
83
+ * `size + 6` need a rule; the rest already sit at or below it and inherit.
84
+ */
85
+ [data-text-size="sm"][data-text-leading="tight"] {
86
+ line-height: 20px;
87
+ }
88
+ [data-text-size="md"][data-text-leading="tight"] {
89
+ line-height: 22px;
90
+ }
91
+ [data-text-size="lg"][data-text-leading="tight"] {
92
+ line-height: 24px;
93
+ }
94
+
85
95
  @media screen and (min-width: 768px) {
86
96
  [data-text-size="xs"] {
87
97
  font-size: 12px;
@@ -111,4 +121,19 @@
111
121
  font-size: 48px;
112
122
  line-height: 52px;
113
123
  }
124
+ [data-text-size="sm"][data-text-leading="tight"] {
125
+ line-height: 20px;
126
+ }
127
+ [data-text-size="md"][data-text-leading="tight"] {
128
+ line-height: 22px;
129
+ }
130
+ [data-text-size="lg"][data-text-leading="tight"] {
131
+ line-height: 26px;
132
+ }
133
+ [data-text-size="xl"][data-text-leading="tight"] {
134
+ line-height: 30px;
135
+ }
136
+ [data-text-size="xxl"][data-text-leading="tight"] {
137
+ line-height: 38px;
138
+ }
114
139
  }
package/src/text.tsx CHANGED
@@ -2,7 +2,7 @@ import "./text.css";
2
2
  import React from "react";
3
3
  import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet, type TextStyle } from "react-native";
4
4
  import { fontFamilyDisplay } from "./display_font";
5
- import { TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
5
+ import { TYPE_LEADING_TIGHT_MOBILE, TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
6
6
  import {
7
7
  fontFamilyMedium,
8
8
  fontFamilyRegular,
@@ -26,6 +26,19 @@ export interface TextProps {
26
26
  * how one screen ends up in a different face from the next.
27
27
  */
28
28
  family?: "display";
29
+ /**
30
+ * `tight` sets the rung's line box for a STACKED PAIR instead of for prose —
31
+ * a subject over its supporting line, a value over its annotation. The two are
32
+ * one object, and prose leading puts enough empty box between them to read as
33
+ * two; beside a mark the pair also grows taller than the mark.
34
+ *
35
+ * Set it on BOTH lines of the pair, not just the second: the box belongs to
36
+ * each line, so tightening one leaves the stack lopsided.
37
+ *
38
+ * Not for running text. A paragraph keeps prose leading, which is what the
39
+ * default is for and what `.ui-markdown` renders at.
40
+ */
41
+ leading?: "tight";
29
42
  align?: TextAlign;
30
43
  weight?: TextWeight;
31
44
  numberOfLines?: number;
@@ -86,6 +99,7 @@ export function Text(props: TextProps) {
86
99
  size = "sm",
87
100
  weight = "regular",
88
101
  family,
102
+ leading,
89
103
  numberOfLines,
90
104
  decoration,
91
105
  tabular,
@@ -105,7 +119,12 @@ export function Text(props: TextProps) {
105
119
 
106
120
  // On web, responsive font sizes are applied via CSS using data-text-size attribute.
107
121
  // On native, sizes are applied via StyleSheet.
108
- const webProps = Platform.OS === "web" ? { dataSet: { textSize: size } } : undefined;
122
+ // `textLeading` is only emitted when tight an absent attribute is the prose
123
+ // default, so no rule has to restate the ramp to say "unchanged".
124
+ const webProps =
125
+ Platform.OS === "web"
126
+ ? { dataSet: leading != null ? { textSize: size, textLeading: leading } : { textSize: size } }
127
+ : undefined;
109
128
 
110
129
  return (
111
130
  <RNText
@@ -127,6 +146,9 @@ export function Text(props: TextProps) {
127
146
  // to, which on web was dead weight the cascade overrode and on native
128
147
  // was the reason the curve never reached the mobile app.
129
148
  Platform.OS !== "web" && sizeStyles[size],
149
+ // AFTER the rung, which set the prose leading — this replaces that one
150
+ // number and leaves size and tracking alone.
151
+ Platform.OS !== "web" && leading === "tight" && tightStyles[size],
130
152
  styles[weight],
131
153
  // AFTER the weight, because the weight ladder sets a family too — each
132
154
  // rung is a distinct Inter family, not a `font-weight` — so the display
@@ -186,6 +208,16 @@ const sizeStyles = StyleSheet.create(
186
208
  ) as Record<TypeRungName, TextStyle>,
187
209
  );
188
210
 
211
+ /** The tight line box, same source, same reason there is no second table. */
212
+ const tightStyles = StyleSheet.create(
213
+ Object.fromEntries(
214
+ (Object.keys(TYPE_LEADING_TIGHT_MOBILE) as TypeRungName[]).map((rung) => [
215
+ rung,
216
+ { lineHeight: TYPE_LEADING_TIGHT_MOBILE[rung] },
217
+ ]),
218
+ ) as Record<TypeRungName, TextStyle>,
219
+ );
220
+
189
221
  const styles = StyleSheet.create({
190
222
  // Text alignment styles
191
223
  left: {
package/src/type_ramp.ts CHANGED
@@ -66,6 +66,40 @@ export const TYPE_RAMP_DESKTOP = {
66
66
  xxxl: { size: 48, leading: 52 },
67
67
  } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, TypeRung>;
68
68
 
69
+ /**
70
+ * TIGHT LEADING — the same rungs, set for a STACKED PAIR rather than for prose.
71
+ *
72
+ * `Text` defaults to the prose leading above, and a stacked pair is not prose: a
73
+ * subject over its supporting line is ONE object, and prose leading puts ~10px of
74
+ * empty box between the two so it reads as two. Beside a 40px mark the pair also
75
+ * measures 50 and overhangs it, which is the visible form of the same fault.
76
+ *
77
+ * DERIVED, not chosen: it is `size + 6`, capped at the rung's own prose leading
78
+ * so a rung already tighter than that keeps what it has. That constant is not
79
+ * invented here — `xs` is 12/18, and has always BEEN `size + 6`. The body rungs
80
+ * are the ones that leave it for a prose ratio, so this is the smallest rung's
81
+ * existing rule extended up the ladder rather than a second opinion about
82
+ * leading. The display rungs mostly already sit at or below it and so are
83
+ * unchanged; the cap is what keeps `xxxl` from being LOOSENED by a rule meant to
84
+ * tighten.
85
+ */
86
+ const TIGHT_LEAD = 6;
87
+
88
+ function tighten(ramp: Record<TypeRungName, TypeRung>): Record<TypeRungName, number> {
89
+ return Object.fromEntries(
90
+ (Object.keys(ramp) as TypeRungName[]).map((rung) => [
91
+ rung,
92
+ Math.min(ramp[rung].leading, ramp[rung].size + TIGHT_LEAD),
93
+ ]),
94
+ ) as Record<TypeRungName, number>;
95
+ }
96
+
97
+ /** Tight leading below 768px, and on native. */
98
+ export const TYPE_LEADING_TIGHT_MOBILE = tighten(TYPE_RAMP_MOBILE);
99
+
100
+ /** Tight leading at 768px and above. */
101
+ export const TYPE_LEADING_TIGHT_DESKTOP = tighten(TYPE_RAMP_DESKTOP);
102
+
69
103
  /**
70
104
  * Tracking per rung, in em — INTER'S OWN dynamic metrics, not a house curve.
71
105
  *