@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
@@ -0,0 +1,140 @@
1
+ import { View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { type ReactNode } from "react";
3
+ import { Text, type TextSize } from "./text";
4
+ import { type TextColor } from "./text_ink";
5
+
6
+ export interface CellStackProps {
7
+ /** The subject — what this cell is ABOUT. */
8
+ title: ReactNode;
9
+ /**
10
+ * The ONE line that qualifies the subject. Omit for a lone value.
11
+ *
12
+ * One, not several: a stack of three is no longer a value and its annotation,
13
+ * it is a row that wants to be a row.
14
+ */
15
+ caption?: ReactNode;
16
+ /**
17
+ * Subject weight. `medium` is itself a channel of demotion between the two
18
+ * lines, so it decides the caption's rung — see the class doc.
19
+ */
20
+ weight?: "regular" | "medium" | "semibold";
21
+ /**
22
+ * Set when the cell opens with an identity mark the CALLER draws (an avatar, a
23
+ * status dot, a file badge). A mark separates the pair exactly as `medium`
24
+ * does, so the caption keeps body size beside one.
25
+ */
26
+ marked?: boolean;
27
+ /** The subject's rung. The pair takes ONE rung, so the caption follows it. */
28
+ size?: TextSize;
29
+ /** The subject's ink. The caption is always `muted` — it is supporting text. */
30
+ color?: TextColor;
31
+ /** Fixed-width numerals on BOTH lines, so a column of figures lines up. */
32
+ tabular?: boolean;
33
+ align?: "left" | "right" | "center";
34
+ /** Clamp each line. A register cell almost always wants 1. */
35
+ numberOfLines?: number;
36
+ style?: StyleProp<ViewStyle>;
37
+ testID?: string;
38
+ }
39
+
40
+ /**
41
+ * A SUBJECT over one qualifying line — the commonest cell in any register, and
42
+ * the one shape the kit stated as a law and never shipped as a part.
43
+ *
44
+ * The rule lives in `docs/composition.md` §Typography and it has three moving
45
+ * pieces that have to agree; every hand-roll got at least one wrong, and the
46
+ * failure is invisible to every spacing probe because the excess height comes
47
+ * from the LINE BOX, so no margin and no gap appears anywhere to explain it.
48
+ * One app carried nine copies drifted three ways — 44px of stack around 26px of
49
+ * type, a ratio of 1.69, across three different gaps.
50
+ *
51
+ * **1. The caption's rung is decided by what ELSE separates the pair**, never by
52
+ * what the line carries. `color="muted"` is already one channel of demotion, so
53
+ * a size step is a second, and a second is only worth its cost where nothing
54
+ * else is doing the job. A `medium` subject or a leading identity mark separates
55
+ * the pair on its own, so the caption stays at body size — which matters most
56
+ * where that line is the DISAMBIGUATOR (which person, at which company), since
57
+ * `xs` demotes content the reader needs and 12px is where stacked diacritics
58
+ * crowd. With both lines regular and colour the only thing between them, the
59
+ * caption drops to `xs` or the two read as peers.
60
+ *
61
+ * **2. Both body-rung lines take `leading="tight"`.** The ramp's body rungs
62
+ * carry PROSE leading, right for a paragraph and wrong for two lines that are
63
+ * one object: it leaves ~10px of empty box between them. Where the caption is
64
+ * already `xs` it is already tight, so only the subject carries the prop.
65
+ *
66
+ * **3. The stack's `gap` is 0.** Each tight line box contributes 3px above and
67
+ * below, which is the 6px the pair wants; a `gap` on top is a third source of
68
+ * space between lines meant to read as one.
69
+ *
70
+ * A LONE line is deliberately NOT tightened. Tight leading is what closes a
71
+ * PAIR up; applied to a single line it just sits oddly against the prose around
72
+ * it, and there is no second line for it to be close to.
73
+ *
74
+ * A caption passed as a NODE keeps its own leading and ink — forcing either on
75
+ * somebody else's subtree would silently re-space something that is not a line
76
+ * of text at all.
77
+ */
78
+ export function CellStack(props: CellStackProps) {
79
+ const {
80
+ title,
81
+ caption,
82
+ weight = "regular",
83
+ marked = false,
84
+ size = "sm",
85
+ color,
86
+ tabular,
87
+ align = "left",
88
+ numberOfLines,
89
+ style,
90
+ testID,
91
+ } = props;
92
+
93
+ const hasCaption = caption !== undefined && caption !== null && caption !== false;
94
+ // Rule 1 — the caption's rung, from what else is already separating the pair.
95
+ const captionSize: TextSize = weight !== "regular" || marked ? size : "xs";
96
+ // Rule 2 — tight closes a PAIR; `xs` is tight by construction, and a lone line
97
+ // has nothing to close up to.
98
+ const subjectTight = hasCaption ? "tight" : undefined;
99
+ const captionTight = captionSize === "xs" ? undefined : "tight";
100
+
101
+ return (
102
+ <View style={[{ gap: 0, alignItems: ALIGN[align] }, style]} testID={testID}>
103
+ {typeof title === "string" || typeof title === "number" ? (
104
+ <Text
105
+ size={size}
106
+ weight={weight}
107
+ color={color}
108
+ leading={subjectTight}
109
+ tabular={tabular}
110
+ numberOfLines={numberOfLines}
111
+ >
112
+ {title}
113
+ </Text>
114
+ ) : (
115
+ title
116
+ )}
117
+ {hasCaption
118
+ ? typeof caption === "string" || typeof caption === "number"
119
+ ? (
120
+ <Text
121
+ size={captionSize}
122
+ color="muted"
123
+ leading={captionTight}
124
+ tabular={tabular}
125
+ numberOfLines={numberOfLines}
126
+ >
127
+ {caption}
128
+ </Text>
129
+ )
130
+ : caption
131
+ : null}
132
+ </View>
133
+ );
134
+ }
135
+
136
+ const ALIGN = {
137
+ left: "flex-start",
138
+ right: "flex-end",
139
+ center: "center",
140
+ } as const satisfies Record<"left" | "right" | "center", ViewStyle["alignItems"]>;
@@ -137,7 +137,7 @@ export function ColumnFilter(props: ColumnFilterProps) {
137
137
  onChange({ kind: "number", min, max: value?.kind === "number" ? value.max : null })
138
138
  }
139
139
  />
140
- <Text size="sm" color="zinc-500">
140
+ <Text size="sm" color="muted">
141
141
 
142
142
  </Text>
143
143
  <NumberInput
package/src/combobox.tsx CHANGED
@@ -483,7 +483,7 @@ export function ComboboxContent(props: ComboboxContentProps) {
483
483
  <View style={styles.menu}>
484
484
  {showRecentsHeader ? (
485
485
  <View style={styles.sectionHeader}>
486
- <Text size="xs" weight="medium" color="zinc-500">
486
+ <Text size="xs" weight="medium" color="muted">
487
487
  {recentsLabel}
488
488
  </Text>
489
489
  </View>
@@ -506,7 +506,7 @@ export function ComboboxContent(props: ComboboxContentProps) {
506
506
  list.searching ? (
507
507
  emptyEl ?? (
508
508
  <View style={styles.statusRow}>
509
- <Text size="sm" color="zinc-500">
509
+ <Text size="sm" color="muted">
510
510
  {emptyText}
511
511
  </Text>
512
512
  </View>
@@ -211,7 +211,7 @@ function CommentRow(props: CommentRowProps) {
211
211
  </View>
212
212
  <View style={{ flex: 1 }}>
213
213
  <Text weight="medium">{displayName}</Text>
214
- <Text size="sm" color="zinc-500">
214
+ <Text size="sm" color="muted">
215
215
  {formatTimestamp(comment.created_at)}
216
216
  {comment.updated_at !== comment.created_at ? ` (${labels.edited})` : ""}
217
217
  </Text>
@@ -292,7 +292,7 @@ export function CommentList(props: CommentListProps) {
292
292
  );
293
293
 
294
294
  if (sorted.length === 0) {
295
- return <>{props.emptyState ?? <Text color="zinc-500">{labels.empty}</Text>}</>;
295
+ return <>{props.emptyState ?? <Text color="muted">{labels.empty}</Text>}</>;
296
296
  }
297
297
 
298
298
  return (
package/src/count.tsx CHANGED
@@ -34,7 +34,7 @@ export function Count(props: CountProps) {
34
34
  size="xs"
35
35
  weight="medium"
36
36
  tabular
37
- color={color === "muted" ? "zinc-500" : "inverted"}
37
+ color={color === "muted" ? "muted" : "onInverse"}
38
38
  userSelect="none"
39
39
  >
40
40
  {count}
@@ -74,7 +74,7 @@ function DayCell(props: {
74
74
  >
75
75
  <Text
76
76
  size="sm"
77
- color={props.isActive ? "inverted" : "default"}
77
+ color={props.isActive ? "onInverse" : "default"}
78
78
  userSelect="none"
79
79
  weight={props.isToday ? "semibold" : "regular"}
80
80
  >
@@ -436,7 +436,7 @@ function CalendarMonth(props: CalendarMonthProps) {
436
436
  <View style={styles.weekHeader}>
437
437
  {daysOfWeekLabels.map((day) => (
438
438
  <View key={day} style={styles.dayHeaderCell}>
439
- <Text size="xs" color="zinc-500">
439
+ <Text size="xs" color="muted">
440
440
  {day}
441
441
  </Text>
442
442
  </View>
@@ -96,7 +96,7 @@ function RangeBound(props: {
96
96
  const { label, placeholder, bound, includeTime, localeTag, onTimeChange } = props;
97
97
  return (
98
98
  <View style={styles.rangeItem}>
99
- <Text size="xs" color="zinc-500">
99
+ <Text size="xs" color="muted">
100
100
  {label}
101
101
  </Text>
102
102
  <Text size="sm" weight="medium" color={bound.date ? "default" : "muted"}>
@@ -2,7 +2,7 @@ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
2
  import { type ReactNode } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
- import { type TextColor } from "./text_utils";
5
+ import { type TextColor } from "./text_ink";
6
6
 
7
7
  /**
8
8
  * A VALUE THAT CHANGED — the atom of every review surface.
@@ -172,7 +172,7 @@ export function DiffValue(props: DiffValueProps) {
172
172
  // zinc-600 — pixel-identical to the field LABEL beside it, so the one word
173
173
  // saying a value is being deleted read as chrome. It describes an absence,
174
174
  // so it sits below the label rather than level with it.
175
- color={hasAfter ? TONE[tone] : "zinc-400"}
175
+ color={hasAfter ? TONE[tone] : "inactive"}
176
176
  weight={hasAfter ? "medium" : "regular"}
177
177
  tabular={tabular}
178
178
  numberOfLines={1}
package/src/eyebrow.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Text } from "./text";
3
- import type { TextColor } from "./text_utils";
3
+ import type { TextColor } from "./text_ink";
4
4
 
5
5
  export interface EyebrowProps {
6
6
  children?: React.ReactNode;
@@ -420,7 +420,7 @@ export function DocumentCard(props: DocumentCardProps) {
420
420
  {...focusProps}
421
421
  >
422
422
  <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
423
- <Text size="xs" numberOfLines={1} color="zinc-500" style={styles.documentTileName} userSelect="none">
423
+ <Text size="xs" numberOfLines={1} color="muted" style={styles.documentTileName} userSelect="none">
424
424
  {filename}
425
425
  </Text>
426
426
  {overlay}
@@ -491,7 +491,7 @@ export function MediaCard(props: MediaCardProps) {
491
491
  </View>
492
492
  </View>
493
493
  <View style={styles.documentCardFooter}>
494
- <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
494
+ <Text size="xs" numberOfLines={1} color="muted" userSelect="none">
495
495
  {filename}
496
496
  </Text>
497
497
  <View style={[styles.typeLabel, { backgroundColor: color }]}>
@@ -219,7 +219,7 @@ function OverflowTile({ size, count, onPress }: { size: number; count: number; o
219
219
  const box = { width: size, height: size, borderRadius: compact ? 7 : 10 };
220
220
  const { focusVisible, focusProps } = useFocusRing();
221
221
  const label = (
222
- <Text size={compact ? "xs" : "sm"} weight="medium" color="zinc-700" userSelect="none">
222
+ <Text size={compact ? "xs" : "sm"} weight="medium" color="muted" userSelect="none">
223
223
  +{count}
224
224
  </Text>
225
225
  );
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useState, type ReactNode } from "react";
1
+ import { Children, createContext, useContext, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Button } from "./button";
4
4
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "./dialog";
@@ -58,6 +58,14 @@ export function FilterBand(props: FilterBandProps) {
58
58
  const t = useLoticsLocale().filterBand;
59
59
  const { small } = useScreenSize();
60
60
  const [open, setOpen] = useState(false);
61
+ // WHETHER THERE IS ANYTHING TO COLLAPSE. `Children.toArray` drops the nulls a
62
+ // caller renders for a facet that has nothing to offer — a status filter whose
63
+ // options are all in view, a round picker before any round is recorded — so a
64
+ // band that draws no chips wide must not draw a button narrow. It did: the wide
65
+ // branch renders `children` and gets nothing, the narrow one rendered the
66
+ // trigger unconditionally, and the two presentations stopped agreeing at exactly
67
+ // the width where a control that opens an empty sheet costs the most.
68
+ const hasFilters = Children.toArray(children).length > 0;
61
69
 
62
70
  if (!small) {
63
71
  return (
@@ -77,14 +85,16 @@ export function FilterBand(props: FilterBandProps) {
77
85
  return (
78
86
  <View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm }}>
79
87
  {search != null && <View style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>{search}</View>}
80
- <Button
81
- color="secondary"
82
- title={activeCount > 0 ? `${t.filters} (${activeCount})` : t.filters}
83
- onPress={() => setOpen(true)}
84
- testID={testID ? `${testID}-filters` : undefined}
85
- />
88
+ {hasFilters && (
89
+ <Button
90
+ color="secondary"
91
+ title={activeCount > 0 ? `${t.filters} (${activeCount})` : t.filters}
92
+ onPress={() => setOpen(true)}
93
+ testID={testID ? `${testID}-filters` : undefined}
94
+ />
95
+ )}
86
96
  {action}
87
- <Dialog open={open} onOpenChange={setOpen} width="92%" maxWidth={520}>
97
+ <Dialog open={open && hasFilters} onOpenChange={setOpen} width="92%" maxWidth={520}>
88
98
  <DialogHeader>
89
99
  <DialogHeaderTitle>{t.filters}</DialogHeaderTitle>
90
100
  </DialogHeader>
@@ -127,7 +127,10 @@ export function FilterChip(props: FilterChipProps) {
127
127
  aria-expanded={isOpen}
128
128
  style={styles.rowHeader}
129
129
  >
130
- <Text size="sm" weight="medium" color="zinc-700">{label}</Text>
130
+ {/* `muted`, not the retired `zinc-700`. This row's LABEL names the facet and
131
+ the summary beside it carries the accent — a label at full ink would
132
+ compete with the value it introduces. */}
133
+ <Text size="sm" weight="medium" color="muted">{label}</Text>
131
134
  <View style={styles.rowRight}>
132
135
  {active ? (
133
136
  typeof summary === "string" ? (
@@ -171,7 +174,7 @@ export function FilterChip(props: FilterChipProps) {
171
174
  // to answer "is anything on?" from across the screen, and ink is the
172
175
  // only channel that carries at that distance. It is a state, not
173
176
  // decoration, which is what makes it a legitimate place for brand.
174
- color={active ? undefined : "zinc-700"}
177
+ color={active ? undefined : "muted"}
175
178
  style={active ? { color: colors.accent } : undefined}
176
179
  numberOfLines={1}
177
180
  >
package/src/finding.tsx CHANGED
@@ -4,6 +4,7 @@ import { Text } from "./text";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
5
  import { useLoticsLocale } from "./locale";
6
6
  import { solid, type ColorName } from "./colors";
7
+ import { useContainerSize } from "./size_boundary";
7
8
 
8
9
  export type FindingSeverity = "critical" | "warning" | "info" | "positive";
9
10
 
@@ -111,6 +112,15 @@ export interface FindingProps {
111
112
  export function Finding(props: FindingProps) {
112
113
  const words = useLoticsLocale().finding;
113
114
  const readings = props.readings ?? [];
115
+ // The headline STACKS where the row is narrow. Side by side, a title long
116
+ // enough to wrap breaks AROUND the figure and the sentence reads as two
117
+ // fragments with a number interposed. It never overflows — it only wraps
118
+ // mid-sentence — so every clipping probe passes, and at desk width the title
119
+ // does not wrap at all, which is why the defect is invisible at the width most
120
+ // reviews measure. The trade recorded below (a clipped delta is worse than a
121
+ // wrapped title) is about CLIPPING and still holds; stacking honours it, since
122
+ // a stacked figure is never clipped either.
123
+ const stacked = useContainerSize().small;
114
124
 
115
125
  return (
116
126
  <View style={[styles.root, props.style]}>
@@ -120,8 +130,8 @@ export function Finding(props: FindingProps) {
120
130
  />
121
131
 
122
132
  <View style={styles.body}>
123
- <View style={styles.headline}>
124
- <Text size="sm" weight="medium" style={styles.title}>
133
+ <View style={stacked ? styles.headlineStacked : styles.headline}>
134
+ <Text size="sm" weight="medium" style={stacked ? styles.titleStacked : styles.title}>
125
135
  {props.title}
126
136
  </Text>
127
137
  {props.delta ? (
@@ -207,7 +217,14 @@ const styles = StyleSheet.create({
207
217
  dot: { width: DOT, height: DOT, borderRadius: DOT / 2, marginTop: TITLE_INSET + (TITLE_LINE - DOT) / 2 },
208
218
  body: { flex: 1, gap: 3, minWidth: 0 },
209
219
  headline: { flexDirection: "row", alignItems: "baseline", gap: 12 },
220
+ // The narrow fork: the title owns its own lines, the figure sits under them on
221
+ // the same left edge as the dot's row — the scan becomes top-to-bottom, which
222
+ // is what a phone reads anyway.
223
+ headlineStacked: { flexDirection: "column", alignItems: "flex-start", gap: 3 },
210
224
  title: { flex: 1, minWidth: 0 },
225
+ // `flex: 1` in a COLUMN grows the box vertically, not horizontally, so the
226
+ // stacked title takes width the other way.
227
+ titleStacked: { alignSelf: "stretch", minWidth: 0 },
211
228
  // Never shrinks: the figure is the point of the row, and a clipped delta is
212
229
  // worse than a title that wraps. Its COLOUR is the severity's, set on the
213
230
  // instance — the dot marks the left edge and the figure the right, so a
@@ -84,7 +84,7 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
84
84
  </Text>
85
85
  )}
86
86
  {!!optional && (
87
- <Text numberOfLines={1} size="sm" color="zinc-500">
87
+ <Text numberOfLines={1} size="sm" color="muted">
88
88
  {resolvedOptionalLabel}
89
89
  </Text>
90
90
  )}
@@ -10,7 +10,7 @@ import { isoHasTime, resolveDateCommit } from "./date_picker_value";
10
10
  import { formatDate } from "./format_date";
11
11
  import { ActivityIndicator } from "./activity_indicator";
12
12
  import { type InlineEditVariant, InlineEditView } from "./inline_edit";
13
- import { type TextColor } from "./text_utils";
13
+ import { type TextColor } from "./text_ink";
14
14
  import { useLoticsLocale, useLocaleTag } from "./locale";
15
15
  import { getInteractionModality } from "./interaction_modality";
16
16
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
@@ -7,7 +7,8 @@ import { ActivityIndicator } from "./activity_indicator";
7
7
  import { FocusRingPressable } from "./focus_ring_pressable";
8
8
  import { colors } from "./colors";
9
9
  import { FOCUS_RING, CONTROL_RADIUS, CONTROL_PADDING_V, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
10
- import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
10
+ import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING, getMultilineInputHeight } from "./text_utils";
11
+ import { getTextColor, type TextColor } from "./text_ink";
11
12
  import { getInteractionModality } from "./interaction_modality";
12
13
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
14
  import { trackCommit } from "./pending_commits";
@@ -208,8 +208,10 @@ export function LineChart(props: LineChartProps) {
208
208
  }
209
209
 
210
210
  const styles = StyleSheet.create({
211
+ // Content-sized, for the reason written out on `BarChart.chartContainer`: a
212
+ // `flex: 1` root resolves to a zero basis inside the vertical stack a card body
213
+ // is, and the chart then paints outside it.
211
214
  chartContainer: {
212
- flex: 1,
213
215
  justifyContent: "center",
214
216
  paddingVertical: 8,
215
217
  },
package/src/list_item.tsx CHANGED
@@ -79,7 +79,7 @@ export function ListItem(props: ListItemProps) {
79
79
  )}
80
80
  {!!description &&
81
81
  (typeof description === "string" ? (
82
- <Text size="sm" color="zinc-500" leading="tight" numberOfLines={1} userSelect="none">
82
+ <Text size="sm" color="muted" leading="tight" numberOfLines={1} userSelect="none">
83
83
  {description}
84
84
  </Text>
85
85
  ) : (
package/src/loading.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Container } from "./container";
2
2
  import { DotsIndicator } from "./dots_indicator";
3
- import { getTextColor } from "./text_utils";
3
+ import { getTextColor } from "./text_ink";
4
4
  import { View } from "react-native";
5
5
 
6
6
  export function Loading() {
package/src/matrix.tsx CHANGED
@@ -213,7 +213,7 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
213
213
  const label = `${row.label}, ${col.label}: ${formatValue(value)}`;
214
214
 
215
215
  const content = showNumber ? (
216
- <Text size="sm" weight="medium" tabular align="right" color={value === 0 ? "zinc-500" : "default"}>
216
+ <Text size="sm" weight="medium" tabular align="right" color={value === 0 ? "muted" : "default"}>
217
217
  {value === 0 ? "" : formatValue(value)}
218
218
  </Text>
219
219
  ) : null;
@@ -129,7 +129,7 @@ export function MemberChip({
129
129
  // supporting line except by colour.
130
130
  weight="medium"
131
131
  leading={pairLeading}
132
- color={inactive ? "zinc-500" : undefined}
132
+ color={inactive ? "muted" : undefined}
133
133
  numberOfLines={1}
134
134
  >
135
135
  {displayName}
@@ -174,7 +174,7 @@ export function MemberProfileCard(props: MemberProfileCardProps) {
174
174
  size it was separated from its own identity line by WEIGHT alone,
175
175
  which is a separate font file rather than a step, and the surface
176
176
  measured 1.17x — flatter than a register with no page band. */}
177
- <Text {...SUBJECT} color={inactive ? "zinc-500" : undefined} numberOfLines={2}>
177
+ <Text {...SUBJECT} color={inactive ? "muted" : undefined} numberOfLines={2}>
178
178
  {displayName}
179
179
  </Text>
180
180
  {identityText ? (
@@ -5,7 +5,7 @@ import { colors } from "./colors";
5
5
  import { CONTROL_RADIUS } from "./control_surface";
6
6
  import { Icon, IconName } from "./icon";
7
7
  import { PressableHighlight } from "./pressable_highlight";
8
- import type { TextColor } from "./text_utils";
8
+ import type { TextColor } from "./text_ink";
9
9
 
10
10
  export interface MenuListItemProps {
11
11
  ref?: Ref<View>;
@@ -77,7 +77,7 @@ export function MenuListItem(props: MenuListItemProps) {
77
77
  title
78
78
  )}
79
79
  {!!description && (
80
- <Text size="xs" color="zinc-500" numberOfLines={1} userSelect="none">
80
+ <Text size="xs" color="muted" numberOfLines={1} userSelect="none">
81
81
  {description}
82
82
  </Text>
83
83
  )}
package/src/metric.tsx CHANGED
@@ -1,7 +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
+ import { getTextColor } from "./text_ink";
5
5
  import { formatCompactNumber, formatMoney } from "./format_money";
6
6
  import { useMemo } from "react";
7
7
 
@@ -141,7 +141,7 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
141
141
  </View>
142
142
  ) : list.rows.length === 0 ? (
143
143
  <View style={styles.statusRow}>
144
- <Text color="zinc-500" size="sm">
144
+ <Text color="muted" size="sm">
145
145
  {emptyText}
146
146
  </Text>
147
147
  </View>
@@ -1,6 +1,5 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
- import { Spacer } from "@lotics/ui/spacer";
4
3
  import { ReactNode } from "react";
5
4
 
6
5
  interface PageHeaderProps {
@@ -62,28 +61,36 @@ export function PageHeader(props: PageHeaderProps) {
62
61
  <View
63
62
  style={{
64
63
  flexDirection: "row",
65
- alignItems: "center",
64
+ // Top-aligned once a description makes the title block taller than the
65
+ // verbs; centring would hang them off the middle of a two-line column.
66
+ alignItems: description ? "flex-start" : "center",
66
67
  flexWrap: "wrap",
67
68
  gap: 12,
68
69
  }}
69
70
  >
70
- <View
71
- style={{
72
- flexDirection: "row",
73
- alignItems: "center",
74
- gap: 8,
75
- flexShrink: 1,
76
- minWidth: 0,
77
- }}
78
- >
79
- {!!title && (
80
- <Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
81
- {title}
82
- </Text>
83
- )}
84
- {/* Never shrinks: the title is what gives way, and a control squeezed
85
- below its own icon is not a smaller control, it is a broken one. */}
86
- {trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
71
+ {/* THE DESCRIPTION RIDES THE TITLE, because it explains the title.
72
+ It used to render after the whole title row — and that row wraps, so at
73
+ a narrow width `actions` dropped to their own line and the reading order
74
+ became title → verbs → the sentence explaining them. That made `actions`
75
+ unusable on any header carrying a description, and the caller's only
76
+ recourse was to hand-roll a second toolbar row and pay for it in
77
+ vertical space at every width.
78
+
79
+ The title is OPTIONAL here: the shell delegates to this component for a
80
+ band that carries only a description, so an absent title must render
81
+ nothing rather than an empty heading line. */}
82
+ <View style={{ flexShrink: 1, minWidth: 0, gap: 4 }}>
83
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
84
+ {!!title && (
85
+ <Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
86
+ {title}
87
+ </Text>
88
+ )}
89
+ {/* Never shrinks: the title is what gives way, and a control squeezed
90
+ below its own icon is not a smaller control, it is a broken one. */}
91
+ {trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
92
+ </View>
93
+ {!!description && <Text color="muted">{description}</Text>}
87
94
  </View>
88
95
  {/* Wrapped so which side gives way is STATED rather than left to whatever
89
96
  the caller happened to pass. A bare `{actions}` inherited its own
@@ -148,12 +155,6 @@ export function PageHeader(props: PageHeaderProps) {
148
155
  ) : (
149
156
  titleRow
150
157
  )}
151
- {!!description && (
152
- <>
153
- {!!title && <Spacer size={8} />}
154
- <Text color="zinc-500">{description}</Text>
155
- </>
156
- )}
157
158
  </View>
158
159
  );
159
160
  }
@@ -87,7 +87,7 @@ export function Pagination(props: PaginationProps): React.ReactNode {
87
87
  return (
88
88
  <View style={styles.container}>
89
89
  <View style={styles.summary}>
90
- <Text size="sm" color="zinc-500">
90
+ <Text size="sm" color="muted">
91
91
  {summary}
92
92
  </Text>
93
93
  </View>
package/src/pie_chart.tsx CHANGED
@@ -149,7 +149,7 @@ export function PieChart(props: PieChartProps) {
149
149
  {item.label}
150
150
  </Text>
151
151
  <Text size="sm" tabular style={styles.legendValue}>{formatNumber(item.value)}</Text>
152
- <Text size="sm" color="zinc-500" tabular style={styles.legendPercentage}>
152
+ <Text size="sm" color="muted" tabular style={styles.legendPercentage}>
153
153
  {percentage.toFixed(0)}%
154
154
  </Text>
155
155
  </View>
@@ -163,8 +163,10 @@ export function PieChart(props: PieChartProps) {
163
163
  }
164
164
 
165
165
  const styles = StyleSheet.create({
166
+ // Content-sized, for the reason written out on `BarChart.chartContainer`: a
167
+ // `flex: 1` root resolves to a zero basis inside the vertical stack a card body
168
+ // is, and the chart then paints outside it.
166
169
  chartContainer: {
167
- flex: 1,
168
170
  justifyContent: "center",
169
171
  },
170
172
  chartWrapper: {