@lotics/ui 43.3.2 → 43.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/AGENTS.md +3 -2
  2. package/MIGRATION.md +93 -0
  3. package/docs/catalog.md +128 -26
  4. package/docs/composition.md +380 -9
  5. package/docs/data_entry.md +25 -0
  6. package/docs/reviewing.md +477 -0
  7. package/docs/templates.md +9 -6
  8. package/examples/tpl_attendance.tsx +0 -1
  9. package/examples/tpl_item_list.tsx +109 -54
  10. package/examples/tpl_record.tsx +89 -4
  11. package/package.json +7 -3
  12. package/src/avatar.tsx +29 -29
  13. package/src/avatar.web.tsx +32 -31
  14. package/src/avatar_props.ts +66 -0
  15. package/src/avatar_tone.ts +79 -0
  16. package/src/button.tsx +36 -4
  17. package/src/checkbox.tsx +4 -1
  18. package/src/choice_list.tsx +5 -3
  19. package/src/color_tokens.ts +15 -14
  20. package/src/composer.tsx +3 -3
  21. package/src/control_surface.ts +37 -9
  22. package/src/copy_button.tsx +8 -1
  23. package/src/counter.tsx +1 -1
  24. package/src/data_grid.tsx +6 -3
  25. package/src/date_calendar.tsx +4 -2
  26. package/src/date_range_filter_field.tsx +5 -1
  27. package/src/date_segments_field.tsx +2 -2
  28. package/src/file_drop_target.web.tsx +2 -2
  29. package/src/file_dropzone.tsx +12 -6
  30. package/src/file_rows.tsx +22 -2
  31. package/src/file_thumbnail.tsx +19 -3
  32. package/src/font_family.ts +1 -1
  33. package/src/font_family.web.ts +1 -1
  34. package/src/funnel.tsx +1 -1
  35. package/src/icon_button.tsx +5 -2
  36. package/src/index.css +22 -16
  37. package/src/inline_edit.tsx +9 -10
  38. package/src/inline_files.tsx +6 -0
  39. package/src/json_panel.tsx +1 -1
  40. package/src/kpi_card.tsx +1 -1
  41. package/src/locale.tsx +1 -1
  42. package/src/markdown.css +5 -1
  43. package/src/member_chip.tsx +19 -2
  44. package/src/metric.tsx +12 -18
  45. package/src/number_input.tsx +2 -2
  46. package/src/option_picker.tsx +57 -0
  47. package/src/picker.tsx +2 -2
  48. package/src/pressable_row.tsx +15 -5
  49. package/src/progress_bar.tsx +1 -1
  50. package/src/radio_picker.tsx +2 -1
  51. package/src/search_input.tsx +12 -11
  52. package/src/sort_header.tsx +7 -3
  53. package/src/stacked_progress_bar.tsx +1 -1
  54. package/src/step_progress.tsx +5 -2
  55. package/src/switch.tsx +11 -6
  56. package/src/table.tsx +148 -16
  57. package/src/table_fit.ts +12 -0
  58. package/src/tabs.tsx +13 -1
  59. package/src/text.css +50 -18
  60. package/src/text.tsx +37 -40
  61. package/src/text_input_field.tsx +2 -2
  62. package/src/text_utils.ts +48 -6
  63. package/src/theme.ts +13 -0
  64. package/src/theme.web.ts +49 -0
  65. package/src/theme_vars.ts +113 -0
  66. package/src/type_ramp.ts +100 -0
  67. package/src/theme.tsx +0 -24
  68. package/src/theme.web.tsx +0 -79
  69. package/src/theme_context.ts +0 -107
package/src/table.tsx CHANGED
@@ -10,13 +10,12 @@ import {
10
10
  } from "react";
11
11
  import { StyleSheet, View, type ViewStyle } from "react-native";
12
12
  import { Text } from "./text";
13
- import { colors } from "./colors";
13
+ import { colors, solid, type ColorName } from "./colors";
14
14
  import { PressableRow } from "./pressable_row";
15
15
  import { PressDoor } from "./press_door";
16
- import { Divider } from "./divider";
17
16
  import { DetailRow } from "./detail_row";
18
17
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
19
- import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
18
+ import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
20
19
  import { ROW_WASH_BLEED } from "./control_surface";
21
20
 
22
21
  /**
@@ -26,7 +25,7 @@ import { ROW_WASH_BLEED } from "./control_surface";
26
25
  * layer in `table_fit.ts` that decides which columns survive a narrow container.
27
26
  */
28
27
  export interface TableColumn extends TableFitColumn {
29
- /** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯).
28
+ /** Header label sentence case, at the BODY size. Omit for a control column.
30
29
  * In stacked mode it renders as the eyebrow over the cell's value. */
31
30
  label?: string;
32
31
  /** Flex grow when no `width` (default 1). */
@@ -37,6 +36,27 @@ export interface TableColumn extends TableFitColumn {
37
36
  }
38
37
 
39
38
 
39
+ /**
40
+ * WHAT SEPARATES ONE ROW FROM THE NEXT: nothing. A register's rows are separated
41
+ * by their own height and a small gap, and the only mark a row makes is the
42
+ * rounded wash it paints when you point at it.
43
+ *
44
+ * There is no hairline and no prop asking for one. A rule between two rows
45
+ * divides LIKE FROM LIKE, which whitespace already says — the register's one
46
+ * line is the band capping the columns, which divides chrome from content and is
47
+ * the thing whitespace cannot say. Drawing both makes them compete, and a grid of
48
+ * equal lines is most of what "it looks like a spreadsheet" means.
49
+ *
50
+ * The wash does the work the rule did, and it already has the shape for it:
51
+ * `PressableRow` bleeds its hover/selected ground past the content and rounds it,
52
+ * so a register reads as a stack of cards without any card component existing.
53
+ *
54
+ * DENSITY, when a surface genuinely needs it, is `TableRow`'s `minHeight` — one
55
+ * number, not a mode. What no caller can ask for is the rules back. A table of
56
+ * VALUES rather than of objects is a different component: `Matrix` for a numeric
57
+ * grid (a statement, a pivot, a rollup), `DataGrid` for a compact one.
58
+ */
59
+
40
60
  interface TableCtx {
41
61
  columns: TableColumn[];
42
62
  leading: number;
@@ -211,6 +231,15 @@ export function Table(props: TableProps) {
211
231
  </View>
212
232
  ) : null
213
233
  ) : (
234
+ /* THE HEADER RULE STAYS IN BOTH RHYTHMS — it is the one line that
235
+ survives `airy`, and it survives because of WHAT it separates rather
236
+ than how important it is. A rule between two rows divides like from
237
+ like, and space says that just as well. This rule divides CHROME
238
+ from CONTENT — a band of labels from the records they name — and
239
+ those are different kinds of thing, so the boundary earns a mark
240
+ that whitespace cannot supply. Dropping it was tried and the column
241
+ band floated: with nothing under them the labels read as a first
242
+ row rather than as the header of the rest. */
214
243
  <View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
215
244
  {/* The TOTAL heads the column of positions — the one number that
216
245
  answers "how many" without the reader scrolling to the last row. */}
@@ -221,7 +250,18 @@ export function Table(props: TableProps) {
221
250
  col.sortable && onSort ? (
222
251
  <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
223
252
  ) : (
224
- <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
253
+ /* A column header is CHROME: `sm` (the body rung, so it is
254
+ legible), `medium` (a weight below the values it heads),
255
+ `muted` (a step back in ink), and SENTENCE CASE. It was
256
+ `xs` semibold uppercase — two sizes below the values, in
257
+ the weight the heading ramp reserves for `#`/`##`/`###`,
258
+ and all-caps, which the eyebrow rule bans outright as the
259
+ loudest templated tell there is. Making a label smaller
260
+ and fainter to make it recede trades legibility for quiet
261
+ and produces the complaint it was meant to fix. `SortHeader`
262
+ and `DataGrid` carry the same treatment; this is where the
263
+ reason lives. */
264
+ <Text size="sm" color="muted" weight="medium" numberOfLines={1}>
225
265
  {col.label}
226
266
  </Text>
227
267
  )
@@ -232,18 +272,84 @@ export function Table(props: TableProps) {
232
272
  </View>
233
273
  )}
234
274
  <View style={styles.body}>
235
- {rows.map((row, i) => (
236
- <View key={i}>
237
- {i > 0 ? <Divider /> : null}
238
- {row}
239
- </View>
240
- ))}
275
+ {rows.map((row, i) => {
276
+ return (
277
+ <View key={i}>{row}</View>
278
+ );
279
+ })}
241
280
  </View>
242
281
  </View>
243
282
  </TableContext.Provider>
244
283
  );
245
284
  }
246
285
 
286
+
287
+ export interface TableGroupProps {
288
+ /** What this band of rows has in common — an ACTION state, not a category. */
289
+ label: string;
290
+ /** How many rows are in it. Shown beside the label; omit only if the band is
291
+ * never partial (it almost always is — a filter or a page can cut it). */
292
+ count?: number;
293
+ /** A palette family for the band's dot, when the group carries a VALENCE the
294
+ * label alone understates (a queue that is overdue). Omit for a neutral band —
295
+ * most bands are neutral, and a dot on every one is decoration. */
296
+ color?: ColorName;
297
+ /** The `TableRow`s in this band. */
298
+ children: ReactNode;
299
+ }
300
+
301
+ /**
302
+ * A titled BAND of rows inside a `Table` — the difference between a register
303
+ * that presents records and one that triages them.
304
+ *
305
+ * A flat register sorted by a column asks the reader to do the deciding: every
306
+ * row arrives at equal weight, and "which of these needs me today" is answered by
307
+ * reading all of them. Grouping moves that decision into the screen. Reach for it
308
+ * when the rows fall into states that imply DIFFERENT ACTIONS — needs a reply,
309
+ * gone quiet, closed — and the reader's real question is which pile they are in.
310
+ *
311
+ * Do NOT reach for it to group by a category the reader can already see in a
312
+ * column: that is a sort, and it costs a band of chrome per value while telling
313
+ * them what the cell beside it says.
314
+ *
315
+ * The band separates itself with AIR rather than a rule, because it is the
316
+ * biggest boundary inside the register and the row hairlines are the smallest —
317
+ * drawing both as lines makes them compete. The heading also takes no rule
318
+ * beneath it, which would orphan the title from the rows it opens.
319
+ *
320
+ * A group's rows keep their own `ordinal` numbering if they carry one: restart
321
+ * inside each band, where the reader's question is "which of these" rather than
322
+ * "which of all".
323
+ */
324
+ export function TableGroup(props: TableGroupProps) {
325
+ const { label, count, color, children } = props;
326
+ const rows = Children.toArray(children).filter(isValidElement);
327
+ // A band separates its OWN rows the same way the table does. This rendered
328
+ // hairlines unconditionally, so an airy register lost its rules between bands
329
+ // and kept them inside — the grouping made the register look half-converted,
330
+ // and the rule that survived was the one with least reason to.
331
+ return (
332
+ <View>
333
+ <View style={styles.groupHeading}>
334
+ {color ? <View style={[styles.groupDot, { backgroundColor: solid(color) }]} /> : null}
335
+ <Text size="sm" weight="semibold">
336
+ {label}
337
+ </Text>
338
+ {count != null ? (
339
+ <Text size="sm" color="zinc-500" tabular>
340
+ {count}
341
+ </Text>
342
+ ) : null}
343
+ </View>
344
+ <View style={styles.body}>
345
+ {rows.map((row, i) => (
346
+ <View key={i}>{row}</View>
347
+ ))}
348
+ </View>
349
+ </View>
350
+ );
351
+ }
352
+
247
353
  export type TableRowProps = {
248
354
  selected?: boolean;
249
355
  /** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
@@ -273,7 +379,9 @@ export type TableRowProps = {
273
379
  * before `trailing`; stacked mode: the content's bottom line, right-aligned — an action
274
380
  * reads at content scale, not as row chrome. Reserve Table `trailing` for it. */
275
381
  action?: ReactNode;
276
- /** Min row height (register mode; stacked rows size to their content). Default 52. */
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. */
277
385
  minHeight?: number;
278
386
  /**
279
387
  * The row's detail, revealed BENEATH it when `expanded` — the alternative to
@@ -330,9 +438,12 @@ export type TableRowProps = {
330
438
  * slots, different geometry.
331
439
  */
332
440
  export function TableRow(props: TableRowProps) {
333
- const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight = 52, detail, expanded, children } = props;
441
+ const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
334
442
  const ctx = useContext(TableContext);
335
443
  if (!ctx) throw new Error("TableRow must be used within a Table");
444
+ // Read AFTER the context check so an airy register does not need every row to
445
+ // repeat its height — the rhythm is a property of the table, not of each row.
446
+ const rowMinHeight = minHeight ?? ROW_HEIGHT;
336
447
  const showDetail = detail != null && expanded === true;
337
448
 
338
449
  const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
@@ -347,7 +458,7 @@ export function TableRow(props: TableRowProps) {
347
458
 
348
459
  // ONE style for the column row, shared by the pressable and read-only paths
349
460
  // below, which render near-identical Views.
350
- const cellsStyle = [styles.cells, { minHeight }];
461
+ const cellsStyle = [styles.cells, { minHeight: rowMinHeight }];
351
462
 
352
463
  const body = ctx.stacked ? (
353
464
  <View style={styles.stackedBody}>
@@ -513,9 +624,30 @@ const styles = StyleSheet.create({
513
624
  borderBottomWidth: 1,
514
625
  borderColor: colors.border,
515
626
  },
516
- // Rows are Divider-separated the hairline between rows is the separation.
627
+ // A small gap, not a large one. The row's own height carries the airiness; the
628
+ // gap only has to stop two washes touching when the pointer moves between
629
+ // them, which at 4px it does while still reading as one list rather than as
630
+ // detached cards.
517
631
  body: {
518
- gap: 0,
632
+ gap: 4,
633
+ },
634
+ // Air, not a rule. A band boundary is the LARGEST break inside a register and
635
+ // the row hairlines are the smallest; drawing both as lines makes the two
636
+ // compete and the grouping stops reading. The first band's heading sits under
637
+ // the column band's own hairline, so it takes less top space than the ones
638
+ // that follow — handled by the heading's own padding rather than by the caller.
639
+ groupHeading: {
640
+ flexDirection: "row",
641
+ alignItems: "center",
642
+ gap: 8,
643
+ paddingTop: 26,
644
+ paddingBottom: 10,
645
+ paddingHorizontal: ROW_GUTTER,
646
+ },
647
+ groupDot: {
648
+ width: 6,
649
+ height: 6,
650
+ borderRadius: 999,
519
651
  },
520
652
  row: {
521
653
  gap: COLUMN_GAP,
package/src/table_fit.ts CHANGED
@@ -30,6 +30,18 @@ export interface TableFitColumn {
30
30
  /** Gap between the header band's / a row's children — `table.tsx` styles consume
31
31
  * this, so the fit math and the rendered geometry can't drift. */
32
32
  export const COLUMN_GAP = 14;
33
+ /**
34
+ * A register row's height. Tall enough that a two-line identity cell (a name over
35
+ * its supporting values) sits in it without crowding, and tall enough that the
36
+ * hover wash reads as a surface rather than a stripe — which is what has to carry
37
+ * the separation now that no hairline does.
38
+ *
39
+ * A caller that genuinely needs density overrides it per row (`TableRow`'s
40
+ * `minHeight`). That is one number, not a mode: what no caller can ask for is the
41
+ * row rules back, because a table of VALUES rather than of objects is a different
42
+ * component (`Matrix`, `DataGrid`).
43
+ */
44
+ export const ROW_HEIGHT = 72;
33
45
  /**
34
46
  * The register's CONTENT gutter — zero, because the washed row bleeds instead.
35
47
  *
package/src/tabs.tsx CHANGED
@@ -120,8 +120,20 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
120
120
  <PressableHighlight
121
121
  focusRing
122
122
  ref={ref} style={{
123
+ // WHERE YOU ARE, painted in the INK THAT ACTS — `colors.primary`, which
124
+ // defaults to near-black. A tab rail is the only thing on a screen that
125
+ // answers "which section am I in", and a neutral grey underline would be
126
+ // indistinguishable from a divider, so it has to be a token.
127
+ //
128
+ // Not `accent`: on an unthemed screen the accent's blue would be the only
129
+ // blue anywhere — a single-member hue from a family used nowhere else,
130
+ // which is the definition of a library default nobody chose, and it sat
131
+ // three pixels from a near-black CTA and near-black checkboxes. `primary`
132
+ // is already the mark the kit spends on "this is on" (a checkbox's fill, a
133
+ // switch's track, a picked option's tick); an active tab is the same claim
134
+ // at page scale, so it reads as one product rather than two brands.
123
135
  borderBottomWidth: 3,
124
- borderBottomColor: selected ? colors.zinc["700"] : "transparent",
136
+ borderBottomColor: selected ? colors.primary : "transparent",
125
137
  backgroundColor: "transparent",
126
138
  paddingBottom: 4,
127
139
  }}
package/src/text.css CHANGED
@@ -1,8 +1,39 @@
1
1
  /*
2
- * Letter-spacing curve: positive at small sizes (improves legibility for
3
- * Vietnamese diacritics + Inter's `1`/`l`/`i` at 12px), neutral at body,
4
- * negative at display. The single biggest "designed" tell on web type —
5
- * Radix, Linear, Geist all use this curve.
2
+ * TWO CURVES, ONE CONTRACT leading and tracking, shared with `.ui-markdown`
3
+ * and the multiline inputs (`INPUT_LINE_HEIGHT_DESKTOP`). Change any one of the
4
+ * three alone and the seam re-opens.
5
+ *
6
+ * LEADING: the body rungs carry PROSE leading — 14px/24px, ratio 1.71, exactly
7
+ * what `.ui-markdown` uses. They were 1.43, four pixels tighter per line than the
8
+ * same 14px text rendered as markdown, so a typed note and a rendered one sat at
9
+ * different rhythms on one surface and the typed one read cramped. The ladder
10
+ * tightens as it rises, which is the convention and what markdown already did:
11
+ * large type needs proportionally less leading, so the display rungs keep their
12
+ * near-1.2 ratios and only the rungs carrying running text move.
13
+ *
14
+ * The cost is real and deliberate: line-height sets the BOX height of even a
15
+ * single line, so every label, value and control gained ~4px of line box.
16
+ * Density is what was traded for the rhythm.
17
+ *
18
+ * TRACKING: NEGATIVE throughout, steepening as the type grows.
19
+ *
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.
26
+ *
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.
6
37
  *
7
38
  * Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
8
39
  * `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
@@ -14,58 +45,59 @@
14
45
 
15
46
  [data-text-size="xs"] {
16
47
  font-size: 12px;
17
- line-height: 16px;
18
- letter-spacing: 0.01em;
48
+ line-height: 18px;
49
+ letter-spacing: -0.02em;
19
50
  }
20
51
  [data-text-size="sm"] {
21
52
  font-size: 14px;
22
- line-height: 20px;
23
- letter-spacing: 0.0025em;
53
+ line-height: 24px;
54
+ letter-spacing: -0.028em;
24
55
  }
25
56
  [data-text-size="md"] {
26
57
  font-size: 16px;
27
- line-height: 22px;
58
+ line-height: 26px;
59
+ letter-spacing: -0.03em;
28
60
  }
29
61
  [data-text-size="lg"] {
30
62
  font-size: 18px;
31
- line-height: 24px;
32
- letter-spacing: -0.005em;
63
+ line-height: 28px;
64
+ letter-spacing: -0.032em;
33
65
  }
34
66
  /* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
35
67
  (page/record title), xxxl = hero numbers. */
36
68
  [data-text-size="xl"] {
37
69
  font-size: 22px;
38
70
  line-height: 28px;
39
- letter-spacing: -0.01em;
71
+ letter-spacing: -0.035em;
40
72
  }
41
73
  [data-text-size="xxl"] {
42
74
  font-size: 28px;
43
75
  line-height: 34px;
44
- letter-spacing: -0.015em;
76
+ letter-spacing: -0.038em;
45
77
  }
46
78
  [data-text-size="xxxl"] {
47
79
  font-size: 32px;
48
80
  line-height: 38px;
49
- letter-spacing: -0.02em;
81
+ letter-spacing: -0.04em;
50
82
  }
51
83
 
52
84
  /* Refer to `use_screen_size` for breakpoints */
53
85
  @media screen and (min-width: 768px) {
54
86
  [data-text-size="xs"] {
55
87
  font-size: 12px;
56
- line-height: 16px;
88
+ line-height: 18px;
57
89
  }
58
90
  [data-text-size="sm"] {
59
91
  font-size: 14px;
60
- line-height: 20px;
92
+ line-height: 24px;
61
93
  }
62
94
  [data-text-size="md"] {
63
95
  font-size: 16px;
64
- line-height: 24px;
96
+ line-height: 26px;
65
97
  }
66
98
  [data-text-size="lg"] {
67
99
  font-size: 20px;
68
- line-height: 28px;
100
+ line-height: 30px;
69
101
  }
70
102
  [data-text-size="xl"] {
71
103
  font-size: 24px;
package/src/text.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import "./text.css";
2
2
  import React from "react";
3
- import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet } from "react-native";
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
6
  import {
6
7
  fontFamilyMedium,
7
8
  fontFamilyRegular,
@@ -61,7 +62,11 @@ export interface TextProps {
61
62
 
62
63
  export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
63
64
 
64
- export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl";
65
+ /**
66
+ * The rungs of the ramp — the set is the ramp's own keys rather than a literal
67
+ * union repeated here, so a rung cannot exist in one and not the other.
68
+ */
69
+ export type TextSize = TypeRungName;
65
70
  type TextAlign = "left" | "right" | "center";
66
71
  type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
67
72
  type TextWeight = "regular" | "medium" | "semibold";
@@ -116,8 +121,12 @@ export function Text(props: TextProps) {
116
121
  {...webProps}
117
122
  style={[
118
123
  { color: getTextColor(color) },
119
- styles.text,
120
- Platform.OS !== "web" && styles[size],
124
+ // Web takes size, leading AND tracking from `text.css` via the
125
+ // `data-text-size` attribute set above; native takes all three here. Nothing
126
+ // sets tracking for both — a flat value applied on both platforms used
127
+ // to, which on web was dead weight the cascade overrode and on native
128
+ // was the reason the curve never reached the mobile app.
129
+ Platform.OS !== "web" && sizeStyles[size],
121
130
  styles[weight],
122
131
  // AFTER the weight, because the weight ladder sets a family too — each
123
132
  // rung is a distinct Inter family, not a `font-weight` — so the display
@@ -153,43 +162,31 @@ const selectStyles = StyleSheet.create({
153
162
  none: { userSelect: "none" },
154
163
  });
155
164
 
156
- const styles = StyleSheet.create({
157
- text: {
158
- letterSpacing: -0.4,
159
- },
160
-
161
- // Text size styles
162
- xs: {
163
- fontSize: 12,
164
- lineHeight: 20,
165
- },
166
- sm: {
167
- fontSize: 14,
168
- lineHeight: 24,
169
- },
170
- md: {
171
- fontSize: 16,
172
- lineHeight: 24,
173
- },
174
- lg: {
175
- fontSize: 18,
176
- lineHeight: 30,
177
- },
178
- // Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
179
- // (page/record title), xxxl = hero numbers.
180
- xl: {
181
- fontSize: 22,
182
- lineHeight: 28,
183
- },
184
- xxl: {
185
- fontSize: 28,
186
- lineHeight: 34,
187
- },
188
- xxxl: {
189
- fontSize: 32,
190
- lineHeight: 38,
191
- },
165
+ /**
166
+ * The native ramp, DERIVED from `type_ramp.ts` rather than written out again.
167
+ * It was a second hand-maintained table and had drifted from the web one on the
168
+ * leading of three rungs and the tracking of all seven — the flat `-0.4` it
169
+ * applied at every size was the whole of native's tracking, so the curve simply
170
+ * did not exist there. Deriving it means there is no longer a second table to
171
+ * drift.
172
+ *
173
+ * Native takes the MOBILE rung because a phone is a phone; the web ramp grows
174
+ * its display sizes above 768px and native has no equivalent breakpoint.
175
+ */
176
+ const sizeStyles = StyleSheet.create(
177
+ Object.fromEntries(
178
+ (Object.keys(TYPE_RAMP_MOBILE) as TypeRungName[]).map((rung) => [
179
+ rung,
180
+ {
181
+ fontSize: TYPE_RAMP_MOBILE[rung].size,
182
+ lineHeight: TYPE_RAMP_MOBILE[rung].leading,
183
+ letterSpacing: trackingPx(rung, TYPE_RAMP_MOBILE[rung].size),
184
+ },
185
+ ]),
186
+ ) as Record<TypeRungName, TextStyle>,
187
+ );
192
188
 
189
+ const styles = StyleSheet.create({
193
190
  // Text alignment styles
194
191
  left: {
195
192
  textAlign: "left",
@@ -13,7 +13,7 @@ import { Ref, useCallback, useState } from "react";
13
13
  import { Icon, IconName } from "./icon";
14
14
  import { IconButton } from "./icon_button";
15
15
  import { ShortcutBadge } from "./shortcut_badge";
16
- import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight } from "./text_utils";
16
+ import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, INPUT_LETTER_SPACING } from "./text_utils";
17
17
  import { useScreenSize } from "./use_screen_size";
18
18
  import { useAutoGrowHeight } from "./use_auto_grow_height";
19
19
  import { useFormField } from "./form_field";
@@ -250,7 +250,7 @@ const styles = StyleSheet.create({
250
250
  paddingVertical: INPUT_PADDING_Y,
251
251
  paddingHorizontal: 8,
252
252
  fontFamily: fontFamilyRegular,
253
- letterSpacing: -0.4,
253
+ letterSpacing: INPUT_LETTER_SPACING,
254
254
  },
255
255
  // The HOST paints the surface AND owns the text inset. Height and vertical
256
256
  // padding stay (they hold the field's size); the horizontal padding goes,
package/src/text_utils.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Platform, TextStyle } from "react-native";
2
2
  import { colors } from "./colors";
3
3
  import { multilineInputHeight } from "./input_metrics";
4
+ import { TYPE_RAMP_DESKTOP, TYPE_RAMP_MOBILE } from "./type_ramp";
4
5
 
5
6
  export type TextColor =
6
7
  | "default"
@@ -51,10 +52,46 @@ export function getTextColor(color?: TextColor): string {
51
52
  // Re-exported here so every existing consumer keeps one import.
52
53
  export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
53
54
 
54
- /** Line height for mobile web (prevents Safari iOS auto-zoom) */
55
- export const INPUT_LINE_HEIGHT_MOBILE = 24;
56
- /** Line height for desktop web and native */
57
- export const INPUT_LINE_HEIGHT_DESKTOP = 20;
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.
59
+ *
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.
64
+ *
65
+ * NOT derived, deliberately, unlike the line heights beside it. An input is 14px
66
+ * on desktop and 16px on mobile, so no single px value is the em at both sizes —
67
+ * 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.
72
+ */
73
+ export const INPUT_LETTER_SPACING = -0.4;
74
+
75
+ /**
76
+ * Line height for mobile web — the input is 16px there (to defeat iOS-Safari
77
+ * focus-zoom), so it takes the `md` rung's leading. DERIVED, because an input is
78
+ * running text and sits at the `Text` rhythm by definition rather than by
79
+ * coincidence.
80
+ *
81
+ * `--input-line-height` in `index.css` is a second copy of this that cannot be
82
+ * avoided — the variable is what actually RENDERS, this constant is what the
83
+ * auto-grow and compact-input math MEASURES with — so `type_ramp.test.ts` asserts
84
+ * the two agree. Disagreeing, they give a field a box computed for a rhythm it
85
+ * does not render.
86
+ */
87
+ export const INPUT_LINE_HEIGHT_MOBILE = TYPE_RAMP_MOBILE.md.leading;
88
+ /**
89
+ * Line height for desktop web and native — 14px text, so the `sm` rung: PROSE
90
+ * leading, the same 24px `Text` and `.ui-markdown` use. A note typed into a field
91
+ * and the same note rendered as markdown have to sit at one rhythm; at 20 the
92
+ * typed version was four pixels tighter per line.
93
+ */
94
+ export const INPUT_LINE_HEIGHT_DESKTOP = TYPE_RAMP_DESKTOP.sm.leading;
58
95
 
59
96
  /**
60
97
  * Input text style for HTML elements on web using CSS variables.
@@ -99,8 +136,13 @@ export const inputTextStyleRN = inputTextStyleWeb as unknown as TextStyle;
99
136
  * Native doesn't have Safari iOS auto-zoom issue, so we use 14px.
100
137
  */
101
138
  const inputTextStyleNative: TextStyle = {
102
- fontSize: 14,
103
- lineHeight: 20,
139
+ fontSize: TYPE_RAMP_DESKTOP.sm.size,
140
+ lineHeight: INPUT_LINE_HEIGHT_DESKTOP,
141
+ // Native was the one input surface carrying NO tracking at all, so a value
142
+ // typed on a phone sat looser than the same value rendered as `Text` a row
143
+ // above it — the only place in the product where an input and a label
144
+ // disagreed on the axis rather than on the amount.
145
+ letterSpacing: INPUT_LETTER_SPACING,
104
146
  // Same ink as the web contract above — an input's text is body ink, not black.
105
147
  color: getTextColor(),
106
148
  };
package/src/theme.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type LoticsTheme } from "./theme_vars";
2
+
3
+ export { THEME_VARS, deriveAccentWash, type LoticsTheme } from "./theme_vars";
4
+
5
+ /**
6
+ * The NATIVE half — a no-op, and honestly so.
7
+ *
8
+ * There are no CSS custom properties off the web, and `colors.ts` is a literal
9
+ * table rather than a set of `var()` reads, so there is nothing a theme could
10
+ * change. Accepting the call and doing nothing keeps ONE call site compiling on
11
+ * both platforms; pretending to theme would be worse than not offering it.
12
+ */
13
+ export function applyLoticsTheme(_theme: LoticsTheme): void {}