@lotics/ui 43.5.0 → 43.6.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 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). |
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. |
@@ -1165,6 +1165,21 @@ 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.
1175
+
1176
+ **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.
1182
+
1168
1183
  **A CONTROL riding a text line must not resize that line.** A control is taller than the text it
1169
1184
  sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
1170
1185
  the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.5.0",
3
+ "version": "43.6.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
@@ -46,39 +46,39 @@
46
46
  [data-text-size="xs"] {
47
47
  font-size: 12px;
48
48
  line-height: 18px;
49
- letter-spacing: -0.02em;
49
+ letter-spacing: 0em;
50
50
  }
51
51
  [data-text-size="sm"] {
52
52
  font-size: 14px;
53
53
  line-height: 24px;
54
- letter-spacing: -0.028em;
54
+ letter-spacing: -0.006em;
55
55
  }
56
56
  [data-text-size="md"] {
57
57
  font-size: 16px;
58
58
  line-height: 26px;
59
- letter-spacing: -0.03em;
59
+ letter-spacing: -0.011em;
60
60
  }
61
61
  [data-text-size="lg"] {
62
62
  font-size: 18px;
63
63
  line-height: 28px;
64
- letter-spacing: -0.032em;
64
+ letter-spacing: -0.014em;
65
65
  }
66
66
  /* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
67
67
  (page/record title), xxxl = hero numbers. */
68
68
  [data-text-size="xl"] {
69
69
  font-size: 22px;
70
70
  line-height: 28px;
71
- letter-spacing: -0.035em;
71
+ letter-spacing: -0.018em;
72
72
  }
73
73
  [data-text-size="xxl"] {
74
74
  font-size: 28px;
75
75
  line-height: 34px;
76
- letter-spacing: -0.038em;
76
+ letter-spacing: -0.021em;
77
77
  }
78
78
  [data-text-size="xxxl"] {
79
79
  font-size: 32px;
80
80
  line-height: 38px;
81
- letter-spacing: -0.04em;
81
+ letter-spacing: -0.022em;
82
82
  }
83
83
 
84
84
  /* Refer to `use_screen_size` for breakpoints */
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,37 @@ 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
+ * Tracking per rung, in em — INTER'S OWN dynamic metrics, not a house curve.
71
+ *
72
+ * Inter publishes the tracking it is drawn for as a function of size:
73
+ *
74
+ * tracking(z) = a + b · e^(c · z) a = -0.0223, b = 0.185, c = -0.1745
75
+ *
76
+ * evaluated at each rung's base size and rounded to a thousandth. The values
77
+ * below ARE that function; they are not tuned by eye and should not be. If a
78
+ * screen reads too tight or too loose, the answer is a size or a leading change,
79
+ * not a nudge here — the typeface's designer has already answered this question
80
+ * for this typeface, and second-guessing it per-rung is how a ramp ends up with
81
+ * seven independent opinions.
82
+ *
83
+ * The shape: near zero at 12px, steepening as the type grows, asymptotic to the
84
+ * -0.0223 the face is drawn to at display sizes. `xs` lands at 0.000, which is
85
+ * also the right answer for Vietnamese stacked diacritics (ự, ộ, ế, ệ) and for
86
+ * Inter's `1`/`l`/`i` at 12px — the sizes where tightening does the most damage
87
+ * are exactly the ones the curve leaves alone.
88
+ *
89
+ * One value per rung, not per breakpoint: an em already scales with the font
90
+ * size, and the curve's difference across a rung's two sizes is under 0.002em.
91
+ */
79
92
  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,
93
+ xs: 0.000,
94
+ sm: -0.006,
95
+ md: -0.011,
96
+ lg: -0.014,
97
+ xl: -0.018,
98
+ xxl: -0.021,
99
+ xxxl: -0.022,
87
100
  } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, number>;
88
101
 
89
102
  /** The rungs, in ladder order. `TextSize` in `text.tsx` is this set. */