@lotics/ui 43.5.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), **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,6 +1165,51 @@ 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
+ **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.
1174
+
1175
+ **The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
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.
1212
+
1168
1213
  **A CONTROL riding a text line must not resize that line.** A control is taller than the text it
1169
1214
  sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
1170
1215
  the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
@@ -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.5.0",
3
+ "version": "43.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/markdown.css CHANGED
@@ -8,7 +8,7 @@
8
8
  carried before, written as an em so it scales with the rung instead of being
9
9
  a flat pixel value that drifts at every other size — and now ONE number
10
10
  shared with `Text` and the inputs rather than markdown's private one. */
11
- letter-spacing: -0.028em;
11
+ letter-spacing: -0.006em;
12
12
  color: rgba(24, 24, 27, 1);
13
13
  }
14
14
 
package/src/metric.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { View, StyleSheet, type TextStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
+ import { getTextColor } from "./text_utils";
4
5
  import { formatCompactNumber, formatMoney } from "./format_money";
5
6
  import { useMemo } from "react";
6
7
 
@@ -46,11 +47,6 @@ const TREND_UP = "↑";
46
47
  const TREND_DOWN = "↓";
47
48
  const TREND_FLAT = "→";
48
49
 
49
- const TONE_COLOR: Record<Exclude<MetricTone, "default">, string> = {
50
- warning: colors.amber[600],
51
- danger: colors.red[600],
52
- };
53
-
54
50
  export function Metric(props: MetricProps) {
55
51
  const {
56
52
  value,
@@ -107,7 +103,17 @@ export function Metric(props: MetricProps) {
107
103
  // none. (Tabular figures do not need it reset either — letter-spacing
108
104
  // adds a constant to every advance, so a tabular column stays aligned.)
109
105
  { fontVariantNumeric: "tabular-nums" } as TextStyle,
110
- tone === "default" ? undefined : { color: TONE_COLOR[tone] },
106
+ // THE VALENCE INK COMES FROM `Text`, not a second map here. This kept
107
+ // its own — amber-600 and red-600 — while `getTextColor` answers
108
+ // amber-700 and red-900 for the same two words, so one `tone="warning"`
109
+ // rendered two different ambers depending on whether the number was a
110
+ // `Metric` or the sentence beside it.
111
+ //
112
+ // And the divergence was not cosmetic: amber-600 measures 3.19:1 on
113
+ // white, under the 4.5:1 AA floor for normal text, which is exactly
114
+ // what `getTextColor`'s own comment says the 700 rung exists to clear.
115
+ // A summary figure is the smallest, most glanced-at text on a register.
116
+ tone === "default" ? undefined : { color: getTextColor(tone) },
111
117
  ]}
112
118
  >
113
119
  {displayValue}
package/src/table.tsx CHANGED
@@ -379,9 +379,19 @@ export type TableRowProps = {
379
379
  * before `trailing`; stacked mode: the content's bottom line, right-aligned — an action
380
380
  * reads at content scale, not as row chrome. Reserve Table `trailing` for it. */
381
381
  action?: ReactNode;
382
- /** Min row height (register mode; stacked rows size to their content).
383
- * Defaults to the Table's rhythm 52 `ruled`, 72 `airy` so one prop on the
384
- * Table changes the whole register and no caller has to restate the beat. */
382
+ /**
383
+ * Min row heighta FLOOR, not a fixed height: a taller cell grows the row,
384
+ * so a two-line identity cell or a figure with a bar under it needs no
385
+ * override to fit. (Stacked rows size to their content and ignore this.)
386
+ *
387
+ * Defaults to `ROW_HEIGHT` (72), the register's rhythm. **Reach for this only
388
+ * when a surface genuinely needs more DENSITY than the beat** — and know that
389
+ * a number written against an older default silently becomes a shrink: an app
390
+ * carrying `minHeight={64}` from when the default was 52 now pulls that one
391
+ * register below every other on the screen, and it reads as cramped rather
392
+ * than as dense. If the reason you overrode it was "the content did not fit",
393
+ * delete the override.
394
+ */
385
395
  minHeight?: number;
386
396
  /**
387
397
  * The row's detail, revealed BENEATH it when `expanded` — the alternative to
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
@@ -46,42 +42,56 @@
46
42
  [data-text-size="xs"] {
47
43
  font-size: 12px;
48
44
  line-height: 18px;
49
- letter-spacing: -0.02em;
45
+ letter-spacing: 0em;
50
46
  }
51
47
  [data-text-size="sm"] {
52
48
  font-size: 14px;
53
49
  line-height: 24px;
54
- letter-spacing: -0.028em;
50
+ letter-spacing: -0.006em;
55
51
  }
56
52
  [data-text-size="md"] {
57
53
  font-size: 16px;
58
54
  line-height: 26px;
59
- letter-spacing: -0.03em;
55
+ letter-spacing: -0.011em;
60
56
  }
61
57
  [data-text-size="lg"] {
62
58
  font-size: 18px;
63
59
  line-height: 28px;
64
- letter-spacing: -0.032em;
60
+ letter-spacing: -0.014em;
65
61
  }
66
62
  /* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
67
63
  (page/record title), xxxl = hero numbers. */
68
64
  [data-text-size="xl"] {
69
65
  font-size: 22px;
70
66
  line-height: 28px;
71
- letter-spacing: -0.035em;
67
+ letter-spacing: -0.018em;
72
68
  }
73
69
  [data-text-size="xxl"] {
74
70
  font-size: 28px;
75
71
  line-height: 34px;
76
- letter-spacing: -0.038em;
72
+ letter-spacing: -0.021em;
77
73
  }
78
74
  [data-text-size="xxxl"] {
79
75
  font-size: 32px;
80
76
  line-height: 38px;
81
- letter-spacing: -0.04em;
77
+ letter-spacing: -0.022em;
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/text_utils.ts CHANGED
@@ -53,24 +53,22 @@ export function getTextColor(color?: TextColor): string {
53
53
  export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
54
54
 
55
55
  /**
56
- * Tracking for input text — the BODY rung of the curve in `text.css` (-0.028em),
57
- * in px because React Native's `letterSpacing` has no em unit. -0.4 IS that rung
58
- * at 14px; the ramp was anchored on this value rather than the other way round.
56
+ * Tracking for input text — the `sm` rung of Inter's curve (`TYPE_TRACKING.sm`,
57
+ * -0.006em) as px, because React Native's `letterSpacing` has no em unit.
59
58
  *
60
- * Six inputs each hard-coded it separately before this. The number did not
61
- * change; what changed is that there is now one of it, next to the line height,
62
- * because they are the same contract — an input is running text and sits at the
63
- * body rhythm on both axes.
59
+ * Six inputs each hard-coded their own value before this; there is one of it now,
60
+ * next to the line height, because they are the same contract an input is
61
+ * running text and sits at the body rhythm on both axes.
64
62
  *
65
63
  * NOT derived, deliberately, unlike the line heights beside it. An input is 14px
66
64
  * on desktop and 16px on mobile, so no single px value is the em at both sizes —
67
65
  * computing one from a rung would pick a size and call the result exact. Written
68
- * out, it stays honestly an approximation: right at 14px, a hair light at 16px
69
- * (-0.4 against -0.45). `type_ramp.test.ts` pins it to within a hundredth of the
70
- * `sm` rung so it cannot drift away from the curve it belongs to; the exact form
71
- * (an `em` string through RN's style types) is not portable.
66
+ * out, it stays honestly an approximation: right at 14px, a hair light at 16px.
67
+ * `type_ramp.test.ts` pins it to within a hundredth of the `sm` rung so it cannot
68
+ * drift away from the curve it belongs to; the exact form (an `em` string through
69
+ * RN's style types) is not portable.
72
70
  */
73
- export const INPUT_LETTER_SPACING = -0.4;
71
+ export const INPUT_LETTER_SPACING = -0.08;
74
72
 
75
73
  /**
76
74
  * Line height for mobile web — the input is 16px there (to defeat iOS-Safari
package/src/type_ramp.ts CHANGED
@@ -24,18 +24,9 @@
24
24
  * every label, value and control gained about 4px of line box. Density was
25
25
  * traded for rhythm.
26
26
  *
27
- * TRACKING: negative throughout, steepening as the type grows. The body rung is
28
- * ANCHORED on -0.028em the -0.4px this product already set on `.ui-markdown`
29
- * and on its inputs, expressed as an em so it scales. That value is the
30
- * product's own, arrived at by reading it in Vietnamese every day, and the rest
31
- * of the ramp is derived FROM it rather than imported from another design
32
- * system. Inter is drawn generously at default tracking, so untracked body reads
33
- * loose beside it, and what looks right at 14px looks slack at 32px.
34
- *
35
- * `xs` is the one rung held back (-0.02em rather than the full body value). 12px
36
- * is where Vietnamese stacked diacritics (ự, ộ, ế, ệ) and Inter's `1`/`l`/`i`
37
- * come closest to colliding, and tightening is the direction that makes both
38
- * worse.
27
+ * TRACKING: Inter's own dynamic-metrics curve, evaluated per rung see
28
+ * `TYPE_TRACKING`. Near zero at 12px and steepening as the type grows, which is
29
+ * the shape the face is drawn for. It is derived, not chosen.
39
30
  *
40
31
  * Tracking is stated ONCE, in em, and is not repeated per breakpoint: an em
41
32
  * scales with the font size, so the same value is correct at both. Only the
@@ -75,15 +66,71 @@ export const TYPE_RAMP_DESKTOP = {
75
66
  xxxl: { size: 48, leading: 52 },
76
67
  } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, TypeRung>;
77
68
 
78
- /** Tracking per rung, in em. Negative at every rung — see the header. */
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
+
103
+ /**
104
+ * Tracking per rung, in em — INTER'S OWN dynamic metrics, not a house curve.
105
+ *
106
+ * Inter publishes the tracking it is drawn for as a function of size:
107
+ *
108
+ * tracking(z) = a + b · e^(c · z) a = -0.0223, b = 0.185, c = -0.1745
109
+ *
110
+ * evaluated at each rung's base size and rounded to a thousandth. The values
111
+ * below ARE that function; they are not tuned by eye and should not be. If a
112
+ * screen reads too tight or too loose, the answer is a size or a leading change,
113
+ * not a nudge here — the typeface's designer has already answered this question
114
+ * for this typeface, and second-guessing it per-rung is how a ramp ends up with
115
+ * seven independent opinions.
116
+ *
117
+ * The shape: near zero at 12px, steepening as the type grows, asymptotic to the
118
+ * -0.0223 the face is drawn to at display sizes. `xs` lands at 0.000, which is
119
+ * also the right answer for Vietnamese stacked diacritics (ự, ộ, ế, ệ) and for
120
+ * Inter's `1`/`l`/`i` at 12px — the sizes where tightening does the most damage
121
+ * are exactly the ones the curve leaves alone.
122
+ *
123
+ * One value per rung, not per breakpoint: an em already scales with the font
124
+ * size, and the curve's difference across a rung's two sizes is under 0.002em.
125
+ */
79
126
  export const TYPE_TRACKING = {
80
- xs: -0.02,
81
- sm: -0.028,
82
- md: -0.03,
83
- lg: -0.032,
84
- xl: -0.035,
85
- xxl: -0.038,
86
- xxxl: -0.04,
127
+ xs: 0.000,
128
+ sm: -0.006,
129
+ md: -0.011,
130
+ lg: -0.014,
131
+ xl: -0.018,
132
+ xxl: -0.021,
133
+ xxxl: -0.022,
87
134
  } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, number>;
88
135
 
89
136
  /** The rungs, in ladder order. `TextSize` in `text.tsx` is this set. */