@lotics/ui 47.6.0 → 47.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.
Files changed (64) hide show
  1. package/AGENTS.md +22 -3
  2. package/MIGRATION.md +61 -62
  3. package/docs/ai_patterns.md +9 -2
  4. package/docs/catalog.md +46 -6
  5. package/docs/composition.md +148 -10
  6. package/docs/data_entry.md +8 -0
  7. package/docs/reviewing.md +51 -2
  8. package/examples/tpl_attendance.tsx +1 -1
  9. package/package.json +3 -1
  10. package/src/alert.tsx +1 -1
  11. package/src/avatar.tsx +1 -1
  12. package/src/avatar.web.tsx +1 -1
  13. package/src/avatar_group.tsx +1 -1
  14. package/src/bar_chart.tsx +10 -1
  15. package/src/board.tsx +1 -1
  16. package/src/button.tsx +4 -4
  17. package/src/cell_stack.tsx +140 -0
  18. package/src/column_filter.tsx +1 -1
  19. package/src/combobox.tsx +2 -2
  20. package/src/comments_thread.tsx +2 -2
  21. package/src/count.tsx +1 -1
  22. package/src/date_calendar.tsx +2 -2
  23. package/src/date_filter.tsx +1 -1
  24. package/src/diff_value.tsx +2 -2
  25. package/src/eyebrow.tsx +1 -1
  26. package/src/file_thumbnail.tsx +2 -2
  27. package/src/file_thumbnail_grid.tsx +1 -1
  28. package/src/filter_band.tsx +18 -8
  29. package/src/filter_chip.tsx +5 -2
  30. package/src/finding.tsx +19 -2
  31. package/src/form_field.tsx +1 -1
  32. package/src/inline_date_picker.tsx +1 -1
  33. package/src/inline_edit.tsx +2 -1
  34. package/src/line_chart.tsx +3 -1
  35. package/src/list_item.tsx +1 -1
  36. package/src/loading.tsx +1 -1
  37. package/src/matrix.tsx +1 -1
  38. package/src/member_chip.tsx +1 -1
  39. package/src/member_profile_card.tsx +1 -1
  40. package/src/menu_list_item.tsx +2 -2
  41. package/src/metric.tsx +1 -1
  42. package/src/option_list.tsx +1 -1
  43. package/src/page_header.tsx +26 -25
  44. package/src/pagination.tsx +1 -1
  45. package/src/pie_chart.tsx +4 -2
  46. package/src/radio_picker.tsx +3 -3
  47. package/src/section_heading.tsx +74 -5
  48. package/src/segmented_control.tsx +1 -1
  49. package/src/select.tsx +1 -1
  50. package/src/shortcut_badge.tsx +1 -1
  51. package/src/stacked_bar_chart.tsx +1 -1
  52. package/src/switcher.tsx +28 -5
  53. package/src/table.tsx +46 -8
  54. package/src/tabs.tsx +2 -2
  55. package/src/text.tsx +2 -7
  56. package/src/text_ink.ts +96 -0
  57. package/src/text_link.tsx +1 -1
  58. package/src/text_utils.ts +1 -46
  59. package/src/thumbnail_stack.tsx +1 -1
  60. package/src/time_picker.tsx +1 -1
  61. package/src/timeline.tsx +1 -1
  62. package/src/tokens.ts +28 -4
  63. package/src/tooltip.tsx +1 -1
  64. package/src/uploading_thumbnail.tsx +1 -1
@@ -5,7 +5,7 @@ import { CONTROL_RADIUS } from "./control_surface";
5
5
  import { Text } from "./text";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
7
  import { Icon } from "./icon";
8
- import { getTextColor } from "./text_utils";
8
+ import { getTextColor } from "./text_ink";
9
9
 
10
10
  export interface RadioPickerOption<T extends string | number | symbol = string> {
11
11
  label: string;
@@ -137,7 +137,7 @@ function RadioOption<T extends string | number | symbol>(
137
137
  alignItems: "center",
138
138
  }}
139
139
  >
140
- {selected && <Icon name="check" size={24} color={getTextColor("inverted")} />}
140
+ {selected && <Icon name="check" size={24} color={getTextColor("onInverse")} />}
141
141
  </View>
142
142
  {/* The text column is width-CONSTRAINED (flex:1, minWidth:0) so a long
143
143
  description WRAPS inside the pressable — unconstrained, its intrinsic
@@ -146,7 +146,7 @@ function RadioOption<T extends string | number | symbol>(
146
146
  <Text>{label}</Text>
147
147
  {/* the annotation standard (FormField metrics): a step smaller and
148
148
  lighter than the label, so the choice reads first */}
149
- {!!description && <Text size="sm" color="zinc-500">{description}</Text>}
149
+ {!!description && <Text size="sm" color="muted">{description}</Text>}
150
150
  </View>
151
151
  </PressableHighlight>
152
152
  );
@@ -1,3 +1,4 @@
1
+ import { Children } from "react";
1
2
  import { View, type StyleProp, type ViewStyle } from "react-native";
2
3
  import { Text, type HeadingLevel, type TextSize } from "./text";
3
4
  import { Icon, type IconName } from "./icon";
@@ -143,7 +144,7 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
143
144
  {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
144
145
  </View>
145
146
  {description ? (
146
- <Text color="zinc-500" size="sm">
147
+ <Text color="muted" size="sm">
147
148
  {description}
148
149
  </Text>
149
150
  ) : null}
@@ -156,10 +157,63 @@ export interface SectionHeadingProps {
156
157
  style?: StyleProp<ViewStyle>;
157
158
  }
158
159
 
160
+ /**
161
+ * The heading's TRAILING slot — every child after the title, as ONE unit.
162
+ *
163
+ * Two things were wrong with laying them out as independent flex items of a
164
+ * wrapping row. With TWO verbs, one stayed on the title's row and the other
165
+ * dropped to the next at a different indent, so a single action group rendered
166
+ * as two unrelated controls; the group must move or stay together, and only the
167
+ * TITLE should ever break away from it. And a trailing child was centred against
168
+ * the title COLUMN — title plus description — so its height came from how many
169
+ * lines that description happened to wrap to, and it landed somewhere different
170
+ * in each section of one screen. It belongs on the title's FIRST line, which is
171
+ * the line box of whichever rung the altitude selected.
172
+ *
173
+ * One child keeps the old geometry exactly: with nothing to hold together there
174
+ * is no group, and the single verb still drops below a title too long to share
175
+ * the row.
176
+ */
159
177
  export function SectionHeading(props: SectionHeadingProps) {
178
+ const children = Children.toArray(props.children);
179
+ const [title, ...trailing] = children;
180
+ const grouped = trailing.length > 1;
181
+
160
182
  return (
161
- <View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12 }, props.style]}>
162
- {props.children}
183
+ <View
184
+ style={[
185
+ { flexDirection: "row", flexWrap: "wrap", gap: 12 },
186
+ // Top-aligned once there is a group, so it rides the title's FIRST line
187
+ // instead of the centre of a column whose height is however many lines
188
+ // the description happened to wrap to. With a lone trailing child there
189
+ // is nothing to hold together and centring is still the right answer.
190
+ { alignItems: grouped ? "flex-start" : "center" },
191
+ props.style,
192
+ ]}
193
+ >
194
+ {grouped ? (
195
+ <>
196
+ {title}
197
+ {/* The group wraps INSIDE itself and shrinks; only its relationship to
198
+ the TITLE is rigid. `flexShrink: 0` here was wrong in the way RN-Web
199
+ punishes: a View is `flexShrink: 0` already, so a group wider than
200
+ the heading never shrinks, never wraps, and paints past the right
201
+ edge — visible to `checkVisibility()`, invisible to an overflow
202
+ check because the parent clips, and simply absent on a phone. The
203
+ `flexShrink: 1` + `minWidth: 0` PAIR is what actually lets it wrap;
204
+ shrink alone floors the box at its min-content width. */}
205
+ <View
206
+ style={{
207
+ flexDirection: "row", alignItems: "center", gap: 12,
208
+ flexWrap: "wrap", flexShrink: 1, minWidth: 0,
209
+ }}
210
+ >
211
+ {trailing}
212
+ </View>
213
+ </>
214
+ ) : (
215
+ props.children
216
+ )}
163
217
  </View>
164
218
  );
165
219
  }
@@ -168,6 +222,20 @@ export interface SectionHeadingTitleProps {
168
222
  children: React.ReactNode;
169
223
  description?: string;
170
224
  icon?: IconName;
225
+ /**
226
+ * A MARK for the thing the heading names — an `Avatar` for a company or person,
227
+ * a logo, a `FileThumbnail`. Rendered inside the title's own column, before the
228
+ * text, so the pair reads as one subject.
229
+ *
230
+ * Distinct from `icon`, which takes an `IconName` and means a CATEGORY: files,
231
+ * comments, a warning. An identity cannot be an icon name, and passing the mark
232
+ * as a sibling of this component instead makes it the heading's FIRST child —
233
+ * which `SectionHeading` reads as the title, so the real title joins the trailing
234
+ * group and the row stops pushing anything to its right edge. That failure is
235
+ * silent: everything renders, nothing clips, and the action simply sits in the
236
+ * middle of the row.
237
+ */
238
+ leading?: React.ReactNode;
171
239
  /**
172
240
  * Heading rank, when the surface's own outline needs one the altitude does not
173
241
  * give it. Leave it unset: the default is the rank of whichever rung the
@@ -221,7 +289,7 @@ const SECTION_RUNG = {
221
289
  * Optional muted `description` line; grows to push any sibling actions to the
222
290
  * right edge. */
223
291
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
224
- const { children, description, icon, level, weight = "semibold", info } = props;
292
+ const { children, description, icon, leading, level, weight = "semibold", info } = props;
225
293
  // Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
226
294
  // render `info` appears or disappears.
227
295
  const words = useLoticsLocale();
@@ -229,6 +297,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
229
297
  return (
230
298
  <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
231
299
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
300
+ {leading}
232
301
  {icon ? <Icon name={icon} size={rung.icon} /> : null}
233
302
  <Text level={level ?? rung.level} weight={weight} size={rung.size}>
234
303
  {children}
@@ -236,7 +305,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
236
305
  {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
237
306
  </View>
238
307
  {description ? (
239
- <Text color="zinc-500" size="sm">
308
+ <Text color="muted" size="sm">
240
309
  {description}
241
310
  </Text>
242
311
  ) : null}
@@ -163,7 +163,7 @@ function Segment<T extends string>(props: SegmentProps<T>) {
163
163
  <Text
164
164
  size="sm"
165
165
  weight={selected ? "medium" : "regular"}
166
- color={selected || hovered ? "zinc-900" : "zinc-700"}
166
+ color={selected || hovered ? "default" : "muted"}
167
167
  numberOfLines={1}
168
168
  userSelect="none"
169
169
  >
package/src/select.tsx CHANGED
@@ -295,7 +295,7 @@ function SelectTrigger<T extends string, D = unknown>({
295
295
  <Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
296
296
  )
297
297
  ) : (
298
- <Text color="zinc-500" userSelect="none">
298
+ <Text color="muted" userSelect="none">
299
299
  {placeholder}
300
300
  </Text>
301
301
  )}
@@ -18,7 +18,7 @@ export function ShortcutBadge(props: ShortcutBadgeProps) {
18
18
 
19
19
  return (
20
20
  <View style={styles.badge}>
21
- <Text size="xs" color="zinc-500" weight="medium" style={{ color: colors.zinc[400] }}>{label}</Text>
21
+ <Text size="xs" color="muted" weight="medium" style={{ color: colors.zinc[400] }}>{label}</Text>
22
22
  </View>
23
23
  );
24
24
  }
@@ -6,7 +6,7 @@ import { useLoticsLocale } from "./locale";
6
6
  import { SPACE } from "./spacing";
7
7
  import { Text } from "./text";
8
8
  import { TYPE_LEADING_TIGHT_DESKTOP, TYPE_LEADING_TIGHT_MOBILE } from "./type_ramp";
9
- import type { TextColor } from "./text_utils";
9
+ import type { TextColor } from "./text_ink";
10
10
 
11
11
  export interface StackedBarSeries {
12
12
  key: string;
package/src/switcher.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useState } from "react";
2
2
  import { StyleSheet } from "react-native";
3
+ import { Avatar } from "./avatar";
3
4
  import { Icon } from "./icon";
4
5
  import { MenuButton } from "./menu_button";
5
6
  import { PressableHighlight } from "./pressable_highlight";
@@ -12,6 +13,15 @@ import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
12
13
  export interface SwitcherItem {
13
14
  id: string;
14
15
  label: string;
16
+ /**
17
+ * An identity image for this item. Optional, and optional PER ITEM rather than
18
+ * per switcher: the marks appear as soon as ONE item carries an image, and an
19
+ * item without one falls back to `Avatar`'s initials. Letting the mark appear
20
+ * only on the items that have a picture is the alternative, and it shifts every
21
+ * unmarked label sideways — so the one entry nobody has photographed is also the
22
+ * one that stops lining up with the rest of the list.
23
+ */
24
+ imageUri?: string;
15
25
  }
16
26
 
17
27
  interface SwitcherProps {
@@ -61,17 +71,29 @@ export function Switcher(props: SwitcherProps) {
61
71
  [currentId, onSelect],
62
72
  );
63
73
 
64
- if (items.length < minItemsToRender) return null;
74
+ // `minItemsToRender` can be lowered to 1 — a switcher that always shows is how a
75
+ // hard scope tells the reader which one they are in — so the empty set is the
76
+ // only one left to answer for, and there is nothing to render for it.
77
+ if (items.length === 0 || items.length < minItemsToRender) return null;
65
78
 
66
- const current = items.find((item) => item.id === currentId);
67
- const label = current?.label ?? items[0].label;
79
+ const current = items.find((item) => item.id === currentId) ?? items[0];
80
+ const withMarks = items.some((item) => item.imageUri);
81
+ // The `md` rung is `CONTROL_CONTENT_HEIGHT` — what a 40px band seats, which
82
+ // both the trigger and a `MenuButton` row are. `square`, because the things a
83
+ // switcher switches BETWEEN are organizations, workspaces and projects; a disc
84
+ // would file them as people.
85
+ const mark = (item: SwitcherItem) =>
86
+ withMarks ? (
87
+ <Avatar shape="square" size="md" name={item.label} source={item.imageUri ? { uri: item.imageUri } : undefined} />
88
+ ) : undefined;
68
89
 
69
90
  return (
70
91
  <Popover open={open} onOpenChange={setOpen} side={side} align={align}>
71
92
  <PopoverTrigger>
72
93
  <PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
73
- <Text size="sm" weight="medium" color="zinc-700" numberOfLines={1}>
74
- {label}
94
+ {mark(current)}
95
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
96
+ {current.label}
75
97
  </Text>
76
98
  <Icon name="chevrons-up-down" size={14} color={colors.zinc["500"]} />
77
99
  </PressableHighlight>
@@ -81,6 +103,7 @@ export function Switcher(props: SwitcherProps) {
81
103
  {items.map((item) => (
82
104
  <MenuButton
83
105
  key={item.id}
106
+ icon={mark(item)}
84
107
  title={item.label}
85
108
  onPress={() => handleSelect(item.id)}
86
109
  right={
package/src/table.tsx CHANGED
@@ -14,6 +14,7 @@ import { Animated, StyleSheet, View, type ViewStyle } from "react-native";
14
14
  import { Text } from "./text";
15
15
  import { colors, solid, type ColorName } from "./colors";
16
16
  import { PressableRow } from "./pressable_row";
17
+ import { CONTROL_HEIGHT } from "./control_surface";
17
18
  import { PressDoor } from "./press_door";
18
19
  import { DetailRow } from "./detail_row";
19
20
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
@@ -72,6 +73,7 @@ interface TableCtx {
72
73
  /** Width of the ordinal gutter, 0 when the register is not counted. */
73
74
  ordinal: number;
74
75
  trailing: number;
76
+ align: "center" | "top";
75
77
  visibleKeys: ReadonlySet<string>;
76
78
  stacked: boolean;
77
79
  }
@@ -182,6 +184,32 @@ export interface TableProps {
182
184
  headerFill?: boolean;
183
185
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
184
186
  trailing?: number;
187
+ /**
188
+ * How a row's cells sit against each other VERTICALLY. Default `center`, which
189
+ * is right while every cell is one line — and wrong the moment one is not.
190
+ *
191
+ * Centring resolves PER CELL, so a cell that grows a second line moves its own
192
+ * content up relative to its single-line siblings, and the trailing control,
193
+ * centred against the tallest cell, drops below the field it acts on. The offset
194
+ * varies with the DATA, so the controls stop forming a column at all — measured
195
+ * at 10px on one row and 27px on another in the same register.
196
+ *
197
+ * `top` puts every cell and both gutters on one top edge. A single-line row is
198
+ * PIXEL-IDENTICAL either way, because the slack centring would have spent becomes
199
+ * padding — so this is safe to pass anywhere and shows only once a row is taller
200
+ * than one line.
201
+ *
202
+ * **Pass `top` on a register that has a TRAILING CONTROL beside multi-line cells.**
203
+ * That is the case it exists for: the control must line up with the field it acts
204
+ * on, and centring puts it wherever the tallest cell leaves it.
205
+ *
206
+ * WITHOUT a trailing control, prefer the default. Centring lets each cell's block
207
+ * sit in the middle of the row, so a one-line value beside a two-line neighbour
208
+ * reads as balanced rather than stranded at the top — and with nothing to line up
209
+ * against, top alignment buys nothing and costs that. Losing the control is a
210
+ * reason to drop `top` again, which is easy to miss because nothing breaks.
211
+ */
212
+ align?: "center" | "top";
185
213
  /** The `TableRow`s. */
186
214
  children: ReactNode;
187
215
  }
@@ -201,7 +229,7 @@ export interface TableProps {
201
229
  * keeps its current order). See `computeTableFit`.
202
230
  */
203
231
  export function Table(props: TableProps) {
204
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, children } = props;
232
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, align = "center", children } = props;
205
233
  // Either prop reserves the gutter; only `count` puts a number in its header.
206
234
  // `LeadGutter` already renders an empty box when it has no number — the same
207
235
  // path a group band takes — so nothing new is needed to draw it.
@@ -222,7 +250,7 @@ export function Table(props: TableProps) {
222
250
  const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
223
251
 
224
252
  return (
225
- <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
253
+ <TableContext.Provider value={{ columns, leading, ordinal, trailing, align, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
226
254
  {/* ONE layout node: without this wrapper the header band + body land as two
227
255
  direct flex children of the app's container, and a parent column `gap`
228
256
  (the standard section spacing) opens a hole between the header and rows. */}
@@ -354,7 +382,7 @@ export function TableGroup(props: TableGroupProps) {
354
382
  {label}
355
383
  </Text>
356
384
  {count != null ? (
357
- <Text size="sm" color="zinc-500" tabular>
385
+ <Text size="sm" color="muted" tabular>
358
386
  {count}
359
387
  </Text>
360
388
  ) : null}
@@ -540,7 +568,12 @@ export function TableRow(props: TableRowProps) {
540
568
 
541
569
  // ONE style for the column row, shared by the pressable and read-only paths
542
570
  // below, which render near-identical Views.
543
- const cellsStyle = [styles.cells, { minHeight: rowMinHeight }];
571
+ const topAligned = ctx.align === "top";
572
+ const alignPad = (ROW_HEIGHT - CONTROL_HEIGHT) / 2;
573
+ const vAlign = topAligned
574
+ ? ({ alignItems: "flex-start", paddingVertical: alignPad } as const)
575
+ : null;
576
+ const cellsStyle = [styles.cells, { minHeight: rowMinHeight }, vAlign];
544
577
 
545
578
  const body = ctx.stacked ? (
546
579
  <View style={styles.stackedBody}>
@@ -574,11 +607,11 @@ export function TableRow(props: TableRowProps) {
574
607
  return <View style={styles.staticStackedRow}>{body}</View>;
575
608
  }
576
609
  return (
577
- <View style={styles.staticRow}>
610
+ <View style={[styles.staticRow, vAlign]}>
578
611
  <LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
579
612
  <View style={cellsStyle}>{cells}</View>
580
613
  {ctx.trailing > 0 ? (
581
- <View style={[styles.trailingSlot, { width: ctx.trailing }]}>
614
+ <View style={[styles.trailingSlot, { width: ctx.trailing }, vAlign]}>
582
615
  {action}
583
616
  {trailing}
584
617
  </View>
@@ -588,7 +621,12 @@ export function TableRow(props: TableRowProps) {
588
621
  }
589
622
 
590
623
  const rowSurface = (
591
- <PressableRow onPress={onPress} selected={selected || showDetail} marked={marked} style={styles.row}>
624
+ // The ROW must stop centring too, not just its cells: `PressableRow` centres the
625
+ // cells block and the trailing gutter as WHOLE blocks, so a taller cells block
626
+ // left the gutter — and the control in it — floating in the middle of the row.
627
+ // Styling the slot's children could never reach that; the row's own axis had to.
628
+ <PressableRow onPress={onPress} selected={selected || showDetail} marked={marked}
629
+ style={topAligned ? [styles.row, { alignItems: "flex-start" as const }] : styles.row}>
592
630
  <ArrivalWash on={justArrived === true} />
593
631
  {!ctx.stacked ? <LeadGutter ordinal={ordinal}>{leading}</LeadGutter> : null}
594
632
  {/* The door hit-tests above in-flow content; the cells/slots lift back above it
@@ -601,7 +639,7 @@ export function TableRow(props: TableRowProps) {
601
639
  <>
602
640
  <View style={cellsStyle}>{cells}</View>
603
641
  {ctx.trailing > 0 ? (
604
- <View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
642
+ <View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }, vAlign]}>
605
643
  {action}
606
644
  {trailing}
607
645
  </View>
package/src/tabs.tsx CHANGED
@@ -121,13 +121,13 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
121
121
  ) : null}
122
122
  <Text
123
123
  weight={selected ? "medium" : "regular"}
124
- color={selected ? "zinc-900" : "zinc-700"}
124
+ color={selected ? "default" : "muted"}
125
125
  userSelect="none"
126
126
  >
127
127
  {option.label}
128
128
  </Text>
129
129
  {option.count != null ? (
130
- <Text color="zinc-500" tabular userSelect="none">
130
+ <Text color="muted" tabular userSelect="none">
131
131
  {option.count}
132
132
  </Text>
133
133
  ) : null}
package/src/text.tsx CHANGED
@@ -3,13 +3,8 @@ 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
5
  import { TYPE_LEADING_TIGHT_MOBILE, TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
6
- import {
7
- fontFamilyMedium,
8
- fontFamilyRegular,
9
- fontFamilySemiBold,
10
- getTextColor,
11
- TextColor,
12
- } from "./text_utils";
6
+ import { fontFamilyMedium, fontFamilyRegular, fontFamilySemiBold } from "./text_utils";
7
+ import { getTextColor, TextColor } from "./text_ink";
13
8
 
14
9
  export interface TextProps {
15
10
  children?: RNTextProps["children"];
@@ -0,0 +1,96 @@
1
+ import { colors } from "./colors";
2
+
3
+ /**
4
+ * TEXT INK, AS AN ALIAS TABLE — the semantic layer, addressable.
5
+ *
6
+ * Two layers, and only one of them is a decision:
7
+ *
8
+ * - The **primitive** layer is the palette (`colors.zinc[600]`). It holds values
9
+ * and owns nothing; it is the same Tailwind ramp anyone could paste.
10
+ * - The **semantic** layer is this table. It holds the actual decisions — "a line
11
+ * that supports the thing above it is one step lighter than body" — and every
12
+ * component addresses ink through it.
13
+ *
14
+ * This used to be a `switch` inside `getTextColor`, which made the semantic layer
15
+ * real but not REFERENCEABLE: the roles existed as a prop union on `Text` and
16
+ * nowhere else, so nothing outside a `.tsx` file could name them. That is why the
17
+ * palette leaked into call sites and into design: a designer picking `zinc-500` in
18
+ * Figma and an author typing `zinc-500` in code look like one source of truth, but
19
+ * the thing they agree on is a THIRD-PARTY RAMP. The decision that supporting text
20
+ * is zinc-600 was written in neither, so it was re-made at every call site — which
21
+ * is exactly how one role came to render in two inks.
22
+ *
23
+ * As a table it serializes: {@link textInkTokens} emits it in the W3C Design
24
+ * Tokens format with the alias intact (`{color.zinc.600}` rather than a flattened
25
+ * hex), which is the form Figma Variables import as ALIAS variables pointing at
26
+ * primitive ones. One file, both tools, and the alias survives the trip — so
27
+ * re-pointing `muted` moves Figma and the code together instead of starting a
28
+ * find-and-replace.
29
+ *
30
+ * RN-free on purpose, so `@lotics/ui/tokens` stays importable by an app that
31
+ * hand-rolls plain DOM without pulling react-native-web in behind it.
32
+ */
33
+ export const TEXT_INK = {
34
+ /** Body copy and any value the reader is meant to land on. */
35
+ default: "zinc.900",
36
+ /** ALL supporting text — under a value, under a heading, anywhere. One role. */
37
+ muted: "zinc.600",
38
+ /** A disabled control's label, and a value that is not filled in ("Not set"). */
39
+ inactive: "zinc.400",
40
+ /** Requires a dark ground — illegal on the canvas. */
41
+ onInverse: "white",
42
+ danger: "red.900",
43
+ warning: "amber.700",
44
+ success: "emerald.700",
45
+ } as const satisfies Record<string, string>;
46
+
47
+ /**
48
+ * Derived from the table, so anything iterating the vocabulary is exhaustive by
49
+ * construction. Declaring the union separately would let a new ink ship without
50
+ * a contrast case and keep every test green.
51
+ */
52
+ export type TextColor = keyof typeof TEXT_INK;
53
+
54
+ export const TEXT_COLORS = Object.keys(TEXT_INK) as TextColor[];
55
+
56
+ /** Resolve a `family.shade` reference against the palette. */
57
+ function resolve(ref: string): string {
58
+ const [family, shade] = ref.split(".");
59
+ const table = colors as unknown as Record<string, unknown>;
60
+ if (shade === undefined) return table[family] as string;
61
+ return (table[family] as Record<string, string>)[shade];
62
+ }
63
+
64
+ export function getTextColor(color?: TextColor): string {
65
+ return resolve(TEXT_INK[color ?? "default"]);
66
+ }
67
+
68
+ /**
69
+ * The vocabulary in the W3C Design Tokens Community Group format, primitives and
70
+ * aliases in one document — the file a Figma Variables import (or Tokens Studio)
71
+ * reads, so the design tool and the code resolve `muted` from the same place.
72
+ *
73
+ * The alias is emitted as a REFERENCE, never a resolved hex. A flattened export
74
+ * would hand Figma seven unrelated colours and lose the only thing worth syncing:
75
+ * that `muted` IS `zinc.600`, and moves when it moves.
76
+ */
77
+ export function textInkTokens(): string {
78
+ const palette = colors as unknown as Record<string, unknown>;
79
+ const color: Record<string, unknown> = {};
80
+ for (const [family, value] of Object.entries(palette)) {
81
+ if (typeof value === "string") {
82
+ color[family] = { $type: "color", $value: value };
83
+ } else if (value && typeof value === "object") {
84
+ const scale: Record<string, unknown> = {};
85
+ for (const [shade, hex] of Object.entries(value as Record<string, string>)) {
86
+ scale[shade] = { $type: "color", $value: hex };
87
+ }
88
+ color[family] = scale;
89
+ }
90
+ }
91
+ const text: Record<string, unknown> = {};
92
+ for (const [role, ref] of Object.entries(TEXT_INK)) {
93
+ text[role] = { $type: "color", $value: `{color.${ref}}` };
94
+ }
95
+ return JSON.stringify({ color, text }, null, 2);
96
+ }
package/src/text_link.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Text, type TextProps } from "./text";
2
2
  import { Icon, type IconName } from "./icon";
3
3
  import { colors } from "./colors";
4
- import { getTextColor } from "./text_utils";
4
+ import { getTextColor } from "./text_ink";
5
5
 
6
6
  export interface TextLinkProps extends TextProps {
7
7
  /** Optional leading icon. */
package/src/text_utils.ts CHANGED
@@ -1,53 +1,8 @@
1
1
  import { Platform, TextStyle } from "react-native";
2
- import { colors } from "./colors";
2
+ import { getTextColor } from "./text_ink";
3
3
  import { multilineInputHeight } from "./input_metrics";
4
4
  import { TYPE_RAMP_DESKTOP, TYPE_RAMP_MOBILE } from "./type_ramp";
5
5
 
6
- export type TextColor =
7
- | "default"
8
- | "muted"
9
- | "inverted"
10
- | "danger"
11
- | "warning"
12
- | "success"
13
- | "zinc-900"
14
- | "zinc-700"
15
- | "zinc-500"
16
- | "zinc-400";
17
-
18
- export function getTextColor(color?: TextColor): string {
19
- switch (color) {
20
- case "default":
21
- return colors.zinc["900"];
22
- case "muted":
23
- return colors.zinc["600"];
24
- case "zinc-900":
25
- return colors.zinc["900"];
26
- case "zinc-700":
27
- return colors.zinc["700"];
28
- case "zinc-500":
29
- return colors.zinc["500"];
30
- // The disabled ink — light enough that a control reads inert at a glance
31
- // (muted/600 still reads as body ink on the control's resting surface).
32
- case "zinc-400":
33
- return colors.zinc["400"];
34
- case "inverted":
35
- return colors.white;
36
- // Valence set — the lightest status weight: a state WORD whose meaning is in
37
- // the word, colored for reinforcement (never color-only). Red sits darker at
38
- // 900; green/amber use 700 to stay recognizably their hue while clearing AA on
39
- // white (600 would fail).
40
- case "danger":
41
- return colors.red["900"];
42
- case "warning":
43
- return colors.amber["700"];
44
- case "success":
45
- return colors.emerald["700"];
46
- default:
47
- return colors.zinc[900];
48
- }
49
- }
50
-
51
6
  // The three rungs live in their own platform-split module — see `font_family.ts`.
52
7
  // Re-exported here so every existing consumer keeps one import.
53
8
  export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
@@ -156,7 +156,7 @@ export function ThumbnailStack(props: ThumbnailStackProps) {
156
156
  >
157
157
  {/* The rung's own type — a fixed size here is how a stack ends up
158
158
  with a 14px count beside 12px marks. */}
159
- <Text size={AVATAR_TEXT[size]} weight="medium" color="zinc-700" aria-hidden>
159
+ <Text size={AVATAR_TEXT[size]} weight="medium" color="muted" aria-hidden>
160
160
  {`+${hidden}`}
161
161
  </Text>
162
162
  </View>
@@ -105,7 +105,7 @@ export function TimePicker(props: TimePickerProps) {
105
105
  {formatTimeOfDay(value, localeTag)}
106
106
  </Text>
107
107
  ) : (
108
- <Text size="sm" color="zinc-500" userSelect="none">
108
+ <Text size="sm" color="muted" userSelect="none">
109
109
  {placeholder}
110
110
  </Text>
111
111
  )}
package/src/timeline.tsx CHANGED
@@ -119,7 +119,7 @@ export function Timeline(props: TimelineProps) {
119
119
  numberOfLines={expanded ? undefined : 2}
120
120
  // The same ink an unfilled field draws (`Not set`, `No date set`),
121
121
  // so an unwritten row reads as unwritten everywhere it appears.
122
- color={item.placeholder ? "zinc-400" : undefined}
122
+ color={item.placeholder ? "inactive" : undefined}
123
123
  style={{ flex: 1 }}
124
124
  >
125
125
  {item.label}