@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
@@ -23,8 +23,23 @@ import {
23
23
  import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
24
24
  import { DiffMark, type DiffKind } from "./diff_mark";
25
25
 
26
+ /** A tile that stands on its own — a grid cell, a card. Big enough to carry a
27
+ * filename under the mark. */
26
28
  export const THUMBNAIL_SIZE = 96;
27
- export const COMPACT_THUMBNAIL_SIZE = 32;
29
+ /**
30
+ * A tile that RIDES A ROW — the file's identity mark, beside the name rather
31
+ * than under it. It is also the threshold: at or below this, a document renders
32
+ * as a bare badge, because there is no room for a name inside the tile and the
33
+ * row already shows one. Above it, the tile becomes a CARD and prints the
34
+ * filename itself, which beside a row that names the file is the same string
35
+ * twice, the second copy truncated.
36
+ *
37
+ * 40 is the register's identity-mark rung — the same number `AVATAR_PX.lg`
38
+ * uses, for the same reason: it is the mark that says which row this is, and a
39
+ * row is 72px tall (`ROW_HEIGHT`). At 32 it read as a stray glyph with 40px of
40
+ * air around it.
41
+ */
42
+ export const COMPACT_THUMBNAIL_SIZE = 40;
28
43
 
29
44
  /**
30
45
  * Returns the icon name for playable media (video/audio) so a MediaCard can
@@ -160,8 +175,9 @@ export function FileThumbnail(props: FileThumbnailProps) {
160
175
  const rootStyle =
161
176
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
162
177
 
163
- // A tile ≤ 32px is a badge in a strip, not a card: a 22px mark would swallow
164
- // it. The row or cell holding the strip carries the change instead.
178
+ // A row-sized tile is a badge, not a card: it has no room for a filename, and
179
+ // a corner mark would swallow it. The row or cell holding it carries the
180
+ // change instead.
165
181
  const compact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
166
182
  const mark = diff === undefined || compact ? null : <DiffCorner kind={diff} />;
167
183
 
@@ -6,7 +6,7 @@
6
6
  * `Inter_600SemiBold`), which is the React Native convention and the reason a
7
7
  * single family token cannot express the ladder on its own.
8
8
  *
9
- * So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
9
+ * So the tokens are per-rung, and `applyLoticsTheme` FANS ONE VALUE OUT
10
10
  * across all three when an app sets `bodyFont`. That is what keeps the app-facing
11
11
  * surface at one parameter while the kit keeps a rung it can default correctly:
12
12
  * unthemed, each rung resolves to its own Inter face; themed, all three resolve
@@ -6,7 +6,7 @@
6
6
  * `Inter_600SemiBold`), which is the React Native convention and the reason a
7
7
  * single family token cannot express the ladder on its own.
8
8
  *
9
- * So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
9
+ * So the tokens are per-rung, and `applyLoticsTheme` FANS ONE VALUE OUT
10
10
  * across all three when an app sets `bodyFont`. That is what keeps the app-facing
11
11
  * surface at one parameter while the kit keeps a rung it can default correctly:
12
12
  * unthemed, each rung resolves to its own Inter face; themed, all three resolve
package/src/funnel.tsx CHANGED
@@ -104,7 +104,7 @@ export function Funnel(props: FunnelProps) {
104
104
  };
105
105
 
106
106
  const titleNode = title ? (
107
- <Text size="xs" color="muted" transform="uppercase">
107
+ <Text size="xs" color="muted" weight="medium">
108
108
  {title}
109
109
  </Text>
110
110
  ) : null;
@@ -139,7 +139,10 @@ const styles = StyleSheet.create({
139
139
  },
140
140
  none: {},
141
141
  primary: {
142
- backgroundColor: colors.zinc[900],
142
+ // The SAME token `Button` primary uses. These two were near-black by
143
+ // coincidence rather than by contract, so an icon-only action migrated
144
+ // from `Button` kept the colour while silently leaving the theme behind.
145
+ backgroundColor: colors.primary,
143
146
  },
144
147
  secondary: {
145
148
  backgroundColor: colors.zinc[100],
@@ -153,7 +156,7 @@ const styles = StyleSheet.create({
153
156
  elevated: {
154
157
  backgroundColor: colors.white,
155
158
  borderWidth: 1,
156
- borderColor: colors.zinc[200],
159
+ borderColor: colors.border,
157
160
  ...({ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.18)" } as object),
158
161
  },
159
162
  });
package/src/index.css CHANGED
@@ -1,20 +1,26 @@
1
+ /*
2
+ * There is NO `--font-size-*` ramp here. Twelve such variables used to sit at the
3
+ * top of this file, and they disagreed with the real ramp in `text.css` on the
4
+ * leading of every rung and the size of two — `sm` handed out 14/20 where `Text`
5
+ * renders 14/24. Nothing in this repo or in any app read them, but the shipped
6
+ * catalog offered them for "hand-rolled DOM surfaces", so the one reader who
7
+ * followed the docs got a ramp that silently disagreed with the text beside it.
8
+ *
9
+ * A hand-rolled DOM surface takes `data-text-size="sm"` instead. `text.css`
10
+ * matches that attribute on ANY element, not just what `Text` renders, and it
11
+ * carries the tracking too — which no set of size/line-height variables can.
12
+ */
1
13
  :root {
2
- --font-size-xs: 12px;
3
- --font-size-xs-line-height: 16px;
4
- --font-size-sm: 14px;
5
- --font-size-sm-line-height: 20px;
6
- --font-size-md: 16px;
7
- --font-size-md-line-height: 24px;
8
- --font-size-lg: 18px;
9
- --font-size-lg-line-height: 26px;
10
- --font-size-xl: 24px;
11
- --font-size-xl-line-height: 30px;
12
- --font-size-xxl: 32px;
13
- --font-size-xxl-line-height: 38px;
14
-
15
- /* Input font size - 16px on mobile to prevent Safari iOS auto-zoom */
14
+ /* Input font size - 16px on mobile to prevent Safari iOS auto-zoom.
15
+ LINE HEIGHT mirrors the `Text` rung at the same size (`text.css`): an input
16
+ is running text and sits at the body rhythm, so 16px takes 26 and 14px takes
17
+ 24. These two values are DUPLICATED as `INPUT_LINE_HEIGHT_MOBILE` /
18
+ `INPUT_LINE_HEIGHT_DESKTOP` in `text_utils.ts`, which the auto-grow and
19
+ compact-input math reads — the variable renders, the constant measures, and
20
+ they must agree. Changing one alone leaves a field whose box is computed for
21
+ a rhythm it does not render. */
16
22
  --input-font-size: 16px;
17
- --input-line-height: 24px;
23
+ --input-line-height: 26px;
18
24
 
19
25
  --font-weight-regular: 400;
20
26
  --font-weight-medium: 500;
@@ -321,7 +327,7 @@
321
327
  @media screen and (min-width: 768px) {
322
328
  :root {
323
329
  --input-font-size: 14px;
324
- --input-line-height: 20px;
330
+ --input-line-height: 24px;
325
331
  }
326
332
  }
327
333
 
@@ -7,7 +7,7 @@ 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, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
10
+ import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
13
  import { trackCommit } from "./pending_commits";
@@ -646,7 +646,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
646
646
  export const inlineValueTextStyle: TextStyle = {
647
647
  ...getInputTextStyle(),
648
648
  fontFamily: fontFamilyRegular,
649
- letterSpacing: -0.4,
649
+ letterSpacing: INPUT_LETTER_SPACING,
650
650
  };
651
651
 
652
652
  // In the view ROW the value text takes the flex slot so the trailing adornment
@@ -728,16 +728,15 @@ const styles = StyleSheet.create({
728
728
  viewHovered: {
729
729
  borderColor: HOVER_BORDER,
730
730
  },
731
- // Hover on a FRAMED field: the border deepens AND the surface tints. The border
732
- // alone would be zinc-200 zinc-400, a shade shift on a 1px line — too quiet to
733
- // mark the field you are about to act on. The tint is the old resting chip,
734
- // promoted to the state it always read as: "this one".
735
- //
736
- // It is deliberately NOT applied to `bare`: those live inside rows that wash on
737
- // hover themselves, and a cell lighter than the row under it reads backwards.
731
+ // Hover on a FRAMED field: THE SAME SIGNAL, and only that. It used to deepen
732
+ // the border AND tint the surface, because at zinc-400 the border shift was
733
+ // too quiet to carry alone a real problem solved in the wrong place. Every
734
+ // other input in the kit hovers by border only, so this made inline fields the
735
+ // one control with a second hover language, and a reader crossing a record
736
+ // surface met both. `HOVER_BORDER` is now zinc-500 and carries it unaided;
737
+ // one property means one thing to theme, and nothing to keep in sync.
738
738
  viewHoveredFramed: {
739
739
  borderColor: HOVER_BORDER,
740
- backgroundColor: colors.zinc[50],
741
740
  },
742
741
  placeholder: { color: colors.zinc[400] },
743
742
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
@@ -85,6 +85,10 @@ export interface InlineFilesProps {
85
85
  emptyLabel?: string;
86
86
  /** Native accept filter, e.g. `"application/pdf,image/*"`. */
87
87
  accept?: string;
88
+ /** What pressing a row does — forwarded to `FileRows`. A field holding
89
+ * documents (a contract, a recording) wants `"open"`; a field holding scans
90
+ * wants the default preview. */
91
+ press?: "preview" | "open";
88
92
  /** Translated gallery/menu chrome, forwarded to `FileRows`. */
89
93
  labels?: Partial<GalleryLabels>;
90
94
  /** Credentials mode for the preview fetches — `"include"` for auth-gated
@@ -122,6 +126,7 @@ export function InlineFiles(props: InlineFilesProps) {
122
126
  onAdd,
123
127
  onRemove,
124
128
  onOpenExternal,
129
+ press,
125
130
  onDownload,
126
131
  multiple = true,
127
132
  addLabel,
@@ -196,6 +201,7 @@ export function InlineFiles(props: InlineFilesProps) {
196
201
  onError={onError}
197
202
  labels={labels}
198
203
  credentials={credentials}
204
+ press={press}
199
205
  />
200
206
  ) : null}
201
207
  {blockedReason !== undefined ? (
@@ -51,7 +51,7 @@ const styles = StyleSheet.create({
51
51
  },
52
52
  body: {
53
53
  borderWidth: 1,
54
- borderColor: colors.zinc[200],
54
+ borderColor: colors.border,
55
55
  borderRadius: 8,
56
56
  backgroundColor: colors.white,
57
57
  // The ScrollView owns the padding (content inset scrolls with the text); the
package/src/kpi_card.tsx CHANGED
@@ -57,7 +57,7 @@ export function KPICard(props: KPICardProps) {
57
57
  return (
58
58
  <View style={[styles.container, style]}>
59
59
  <View style={styles.labelRow}>
60
- <Text size="xs" color="muted" transform="uppercase">
60
+ <Text size="xs" color="muted" weight="medium">
61
61
  {label}
62
62
  </Text>
63
63
  {info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
package/src/locale.tsx CHANGED
@@ -609,7 +609,7 @@ interface LoticsLocaleProviderProps {
609
609
 
610
610
  /**
611
611
  * App-root provider that supplies localized strings to @lotics/ui primitives —
612
- * the sibling of `LoticsThemeProvider`. Wrap your top-level element once and
612
+ * the kit's one root provider. Wrap your top-level element once and
613
613
  * every wired component picks up the pack; no per-instance label props:
614
614
  *
615
615
  * // src/main.tsx
package/src/markdown.css CHANGED
@@ -4,7 +4,11 @@
4
4
  "Helvetica Neue", sans-serif;
5
5
  font-size: 14px;
6
6
  line-height: 24px;
7
- letter-spacing: -0.4px;
7
+ /* The body rung of the curve in `text.css`. Same tightness as the -0.4px this
8
+ carried before, written as an em so it scales with the rung instead of being
9
+ a flat pixel value that drifts at every other size — and now ONE number
10
+ shared with `Text` and the inputs rather than markdown's private one. */
11
+ letter-spacing: -0.028em;
8
12
  color: rgba(24, 24, 27, 1);
9
13
  }
10
14
 
@@ -15,6 +15,11 @@ interface MemberChipProps {
15
15
  /** A rung on the shared avatar scale. Default `md`, which is sized to seat in a
16
16
  * 40px control band; drop to `sm` for a dense cell. See `AVATAR_PX`. */
17
17
  size?: AvatarSize;
18
+ /** This person is no longer active — they left, their account was closed.
19
+ * Mutes the name so a record that still names them reads as history rather
20
+ * than as a current assignment. The chip keeps its height, because its usual
21
+ * home is a fixed row: this is a colour change, never an extra line. */
22
+ inactive?: boolean;
18
23
  style?: StyleProp<ViewStyle>;
19
24
  }
20
25
 
@@ -34,7 +39,14 @@ interface MemberChipProps {
34
39
  * preserve by hand: it is why `md` is 28 rather than the 36 that used to grow
35
40
  * those three surfaces to 46, 50 and 54 for the very same chip.
36
41
  */
37
- export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
42
+ export function MemberChip({
43
+ name,
44
+ image,
45
+ secondary,
46
+ size = "md",
47
+ inactive,
48
+ style,
49
+ }: MemberChipProps) {
38
50
  const displayName = name?.trim() || "Unknown";
39
51
  // THE TEXT FOLLOWS THE RUNG. `size` used to scale the avatar alone, so a chip
40
52
  // asked for the dense rung got a 24px avatar beside a 14px name — half-dense,
@@ -50,7 +62,12 @@ export function MemberChip({ name, image, secondary, size = "md", style }: Membe
50
62
  <View style={[styles.row, style]}>
51
63
  <Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
52
64
  <View style={styles.text}>
53
- <Text userSelect="none" size={textSize} numberOfLines={1}>
65
+ <Text
66
+ userSelect="none"
67
+ size={textSize}
68
+ color={inactive ? "zinc-500" : undefined}
69
+ numberOfLines={1}
70
+ >
54
71
  {displayName}
55
72
  </Text>
56
73
  {secondary ? (
package/src/metric.tsx CHANGED
@@ -41,15 +41,6 @@ const SIZE_TO_TEXT_SIZE: Record<MetricSize, "md" | "lg" | "xl" | "xxl"> = {
41
41
  hero: "xxl",
42
42
  };
43
43
 
44
- // Letter-spacing tightens as font-size grows — "designed" not "inflated".
45
- // Hero gets the tightest tracking (-1.2) to match the 48px display size's
46
- // expected condensation.
47
- const SIZE_TO_LETTER_SPACING: Record<MetricSize, number> = {
48
- sm: 0,
49
- md: -0.25,
50
- lg: -0.5,
51
- hero: -1.2,
52
- };
53
44
 
54
45
  const TREND_UP = "↑";
55
46
  const TREND_DOWN = "↓";
@@ -104,15 +95,18 @@ export function Metric(props: MetricProps) {
104
95
  size={SIZE_TO_TEXT_SIZE[size]}
105
96
  weight="medium"
106
97
  style={[
107
- // Tabular nums prevent the comma/decimal jitter when 1,234 sits
108
- // next to 7,890 across cards — proportional glyphs misalign every
109
- // column. Tighter tracking at display sizes reads as "designed",
110
- // not "inflated body text" (Linear / Stripe / Geist all do this
111
- // at hero metrics).
112
- {
113
- fontVariantNumeric: "tabular-nums",
114
- letterSpacing: SIZE_TO_LETTER_SPACING[size],
115
- } as TextStyle,
98
+ // Tabular nums prevent the comma/decimal jitter when 1,234 sits next
99
+ // to 7,890 across cards — proportional glyphs misalign every column.
100
+ //
101
+ // TRACKING IS NOT SET HERE. This carried its own size→tracking table,
102
+ // which was a second copy of the ramp's curve and disagreed with it at
103
+ // every rung — `sm` sat at ZERO while the 16px text beside it tracked
104
+ // -0.48px, so every summary figure in the product was the one number
105
+ // not tracking like its own sentence. `Text` already applies the rung's
106
+ // value from `text.css`; the correct amount of tracking to add here is
107
+ // none. (Tabular figures do not need it reset either — letter-spacing
108
+ // adds a constant to every advance, so a tabular column stays aligned.)
109
+ { fontVariantNumeric: "tabular-nums" } as TextStyle,
116
110
  tone === "default" ? undefined : { color: TONE_COLOR[tone] },
117
111
  ]}
118
112
  >
@@ -1,7 +1,7 @@
1
1
  import type { KeyboardEvent } from "react";
2
2
  import { colors } from "./colors";
3
3
  import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
4
- import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
4
+ import { fontFamilyRegular, inputTextStyleWeb, INPUT_LETTER_SPACING } from "./text_utils";
5
5
  import { useFormField } from "./form_field";
6
6
  import { useFocusRing } from "./use_focus_ring";
7
7
  import { useHover } from "./use_hover";
@@ -75,7 +75,7 @@ export function NumberInput(props: NumberInputProps) {
75
75
  backgroundColor: seamless ? "transparent" : colors.background,
76
76
  fontFamily: fontFamilyRegular,
77
77
  ...inputTextStyleWeb,
78
- letterSpacing: -0.4,
78
+ letterSpacing: INPUT_LETTER_SPACING,
79
79
  boxShadow: !seamless && focusVisible ? FOCUS_RING : "none",
80
80
  outline: "none",
81
81
  boxSizing: "border-box",
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+ import { OptionBadge, type OptionValue } from "./option_badge";
3
+ import type { PickerOption } from "./picker";
4
+
5
+ /**
6
+ * Feed a select FIELD's options into any kit picker — `InlineSelect`, `Select`,
7
+ * `OptionList`, `Combobox` — without dropping what the field knows.
8
+ *
9
+ * <InlineSelect {...optionPicker(fields.city?.options ?? [])} … /> // a category
10
+ * <InlineSelect {...optionPicker(fields.status?.options ?? [], { badge: "dot" })} … /> // a status
11
+ *
12
+ * It exists because the obvious call site loses information: a picker takes
13
+ * `{ value, label }`, so every app writes
14
+ * `options.map((o) => ({ value: o.key, label: o.label }))` — and the option's
15
+ * `color` is not in that shape, so it never arrives.
16
+ *
17
+ * **`badge` is OPT-IN, and that is the whole design.** A badge means STATUS —
18
+ * a lifecycle, a stage, a risk level, something that reads at a glance and
19
+ * changes what you do. A type, a category, an attribute or a count is NOT a
20
+ * status: an industry, a source, a department, a headcount band, **a city**.
21
+ * Those render as plain text, and the composition grammar says so in those
22
+ * words. Colouring them anyway is the failure this default prevents — every
23
+ * option in every picker becomes a chip, the vocabulary that made a coloured
24
+ * pill MEAN something is spent on where a company happens to be, and the screen
25
+ * reads as decorated rather than organised. A field carrying a `color` is not
26
+ * consent to paint it; someone configures colours on a field for the one surface
27
+ * that badges it, and every other surface still has to decide.
28
+ *
29
+ * So the safe answer is the default: omit `badge` and you get text, which is
30
+ * correct for the majority of select fields. Pass it where the value is a status
31
+ * and you want it recognised rather than read.
32
+ *
33
+ * `dot` over `tonal` when you do badge: a picker row is a place to READ a set of
34
+ * choices, and a stack of filled pills is heavier than the list describing them.
35
+ */
36
+ export function optionPicker(
37
+ options: OptionValue[],
38
+ opts?: { badge?: "tonal" | "dot" },
39
+ ): {
40
+ options: PickerOption<string, OptionValue>[];
41
+ renderOptionContent?: (option: PickerOption<string, OptionValue>) => React.ReactNode;
42
+ } {
43
+ const badge = opts?.badge;
44
+ return {
45
+ // `key` is what the platform stores and what a write must send back; the
46
+ // label is display text that can be renamed under you. Falling back to the
47
+ // label keeps a hand-built option list working rather than producing an
48
+ // option whose value is `undefined`.
49
+ options: options.map((o) => ({ value: o.key ?? o.label, label: o.label, data: o })),
50
+ // Absent, not a no-op renderer: the pickers fall back to their own label
51
+ // rendering, so an un-badged field looks exactly like every other text value
52
+ // on the surface rather than like a badge that failed to paint.
53
+ renderOptionContent: badge
54
+ ? (option) => (option.data ? <OptionBadge value={option.data} variant={badge} /> : null)
55
+ : undefined,
56
+ };
57
+ }
package/src/picker.tsx CHANGED
@@ -5,7 +5,7 @@ import { colors } from "./colors";
5
5
  import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
6
6
  import { useFocusRing } from "./use_focus_ring";
7
7
  import { useHover } from "./use_hover";
8
- import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
8
+ import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
9
9
  import { Icon } from "./icon";
10
10
  import { useLoticsLocale } from "./locale";
11
11
  import { useFormField } from "./form_field";
@@ -188,7 +188,7 @@ const styles = StyleSheet.create({
188
188
  borderWidth: 1,
189
189
  borderColor: colors.border,
190
190
  fontFamily: fontFamilyRegular,
191
- letterSpacing: -0.4,
191
+ letterSpacing: INPUT_LETTER_SPACING,
192
192
  backgroundColor: "transparent",
193
193
  appearance: "none",
194
194
  },
@@ -86,11 +86,21 @@ export function PressableRow(props: PressableRowProps) {
86
86
  styles.row,
87
87
  variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
88
88
  {
89
- // Hover and selection wear the brand's WASH, whose default IS the zinc-100
90
- // these states already used so an unthemed app is unchanged and a themed
91
- // one warms every row it touches. `pressed` stays neutral and one step
92
- // darker: it is a momentary depth cue, not an identity.
93
- backgroundColor: pressed ? colors.zinc[200] : selected ? colors.accent_wash : hovered ? colors.accent_wash : marked ? colors.blue[50] : undefined,
89
+ // FOUR states, and they must not collide. Selection and hover painted
90
+ // the SAME wash, which made "the row whose record is open" and "the row
91
+ // the pointer happens to be over" indistinguishable the register's
92
+ // one piece of persistent state, erased by a transient one. Hover is
93
+ // now neutral and lighter, because it answers "you can press this",
94
+ // while selection carries the brand's WASH because it answers "you are
95
+ // here" — the state the accent exists for. `pressed` stays neutral and
96
+ // darkest: a momentary depth cue, not an identity.
97
+ // `marked` shares the wash rather than keeping its own literal blue-50:
98
+ // that hex was the same colour the wash now resolves to, minus the
99
+ // theming, so a branded app painted its ticked rows in a hue it had
100
+ // replaced everywhere else. What separates a ticked row from an open
101
+ // one is the ticked checkbox, which is unmissable; two washes for two
102
+ // orthogonal states could never render anyway on a row that is both.
103
+ backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.accent_wash : hovered ? colors.zinc[50] : undefined,
94
104
  },
95
105
  style,
96
106
  ]}
@@ -112,7 +112,7 @@ export function ProgressBar(props: ProgressBarProps) {
112
112
  {title || caption ? (
113
113
  <View style={styles.header}>
114
114
  {title ? (
115
- <Text size="xs" color="muted" transform="uppercase">
115
+ <Text size="xs" color="muted" weight="medium">
116
116
  {title}
117
117
  </Text>
118
118
  ) : null}
@@ -131,7 +131,8 @@ function RadioOption<T extends string | number | symbol>(
131
131
  borderRadius: 999,
132
132
  borderWidth: 1,
133
133
  borderColor: colors.border,
134
- backgroundColor: selected ? colors.zinc["900"] : colors.zinc["50"],
134
+ // Selected = the primary token, like every other "this one is on".
135
+ backgroundColor: selected ? colors.primary : colors.zinc["50"],
135
136
  justifyContent: "center",
136
137
  alignItems: "center",
137
138
  }}
@@ -63,19 +63,20 @@ export function SearchInput(props: SearchInputProps) {
63
63
 
64
64
  const styles = StyleSheet.create({
65
65
  // Search reads as a view-control by its leading search glyph + white fill —
66
- // NOT by a distinct shape. It shares the kit-wide `CONTROL_RADIUS` of every other
67
- // input/picker (TextInputField, Combobox, the date filter), so a toolbar of
68
- // controls reads as one consistent band. The white fill still pops against
69
- // the zinc-50 canvas where most toolbars live; a zinc-300 border (one step up
70
- // from the form-input hairline) keeps it defined on white cards too, where
71
- // the fill alone would vanish. 40px (TextInputField default) the system
72
- // control height every band aligns to.
66
+ // NOT by a distinct shape. It shares the kit-wide `CONTROL_RADIUS`, the 40px
67
+ // control height, AND the resting `colors.border` hairline of every other
68
+ // input and picker, so a toolbar reads as one band of one species.
69
+ //
70
+ // It used to take `zinc-300`, one step heavier, on the argument that the white
71
+ // fill would vanish "on white cards". That defended against the wrong thing:
72
+ // a search input is never alone, it sits in a band beside filters and pickers,
73
+ // and measured in one there was exactly one control with a darker edge than
74
+ // its six neighbours — a difference a reader parses as an accident, not as
75
+ // emphasis. Definition on a white ground is the hairline's job everywhere else
76
+ // in the kit; there is no reason this control is the exception.
73
77
  search: {
74
78
  borderRadius: CONTROL_RADIUS,
75
79
  backgroundColor: colors.white,
76
- // A thin 1px resting border (TextInputField's default width) — interaction
77
- // emphasis comes from the input's own focus outline, so it stays quiet at
78
- // rest. zinc-300 keeps it defined on white cards.
79
- borderColor: colors.zinc[300],
80
+ borderColor: colors.border,
80
81
  },
81
82
  });
@@ -95,10 +95,14 @@ export function SortHeader(props: SortHeaderProps) {
95
95
  >
96
96
  {align === "right" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
97
97
  <Text
98
- size="xs"
98
+ size="sm"
99
99
  color={active ? "default" : "muted"}
100
- weight={active ? "medium" : "regular"}
101
- transform="uppercase"
100
+ // ONE weight, sorted or not. The active column is marked by INK (and by
101
+ // its direction chevron), never by stepping up to semibold: semibold is
102
+ // the heading ladder's weight in this system, and a sorted column is a
103
+ // label like the ones beside it, not a heading. A sortable and a plain
104
+ // header share a band and must read as one row of labels.
105
+ weight="medium"
102
106
  numberOfLines={1}
103
107
  >
104
108
  {label}
@@ -61,7 +61,7 @@ export function StackedProgressBar(props: StackedProgressBarProps) {
61
61
  <View style={styles.container}>
62
62
  <View style={styles.header}>
63
63
  {title ? (
64
- <Text size="xs" color="muted" transform="uppercase">
64
+ <Text size="xs" color="muted" weight="medium">
65
65
  {title}
66
66
  </Text>
67
67
  ) : null}
@@ -45,7 +45,10 @@ export interface StepProgressProps {
45
45
  * stage-countable at card density → this.
46
46
  */
47
47
  export function StepProgress(props: StepProgressProps) {
48
- const { steps, current, color = colors.zinc[900], title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
48
+ // The DEFAULT fill is the primary token progress is the surface saying "this
49
+ // much is done", the same claim the one filled button makes. Still a prop, so a
50
+ // caller that means a specific hue (a status, a series) passes one.
51
+ const { steps, current, color = colors.primary, title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
49
52
  const captionColor = captionTone === "danger" ? "danger" : "muted";
50
53
  const names = typeof steps === "number" ? null : steps;
51
54
  const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
@@ -94,7 +97,7 @@ export function StepProgress(props: StepProgressProps) {
94
97
  return (
95
98
  <View style={{ gap: 6, flex: 1 }}>
96
99
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
97
- <Text size="xs" color="muted" transform="uppercase">
100
+ <Text size="xs" color="muted" weight="medium">
98
101
  {title}
99
102
  </Text>
100
103
  <View style={{ flex: 1 }} />
package/src/switch.tsx CHANGED
@@ -37,14 +37,17 @@ export function Switch(props: SwitchProps) {
37
37
 
38
38
  const content = (
39
39
  <Animated.View
40
+ // The ON track is `colors.primary`, applied as a PLAIN style rather than
41
+ // through the interpolation the thumb uses. That split is forced, not a
42
+ // preference: `Animated.interpolate` PARSES its `outputRange` colours in
43
+ // order to tween between them, and on web `colors.primary` is a `var()` —
44
+ // a string no colour parser can read, which would yield garbage no type
45
+ // would catch. So the track's colour switches instantly while the thumb
46
+ // keeps its spring; OFF keeps the literal zinc-200, which was never a
47
+ // token and carries no brand claim.
40
48
  style={[
41
49
  styles.wrapper,
42
- {
43
- backgroundColor: checked.interpolate({
44
- inputRange: [0, 1],
45
- outputRange: [colors.zinc["200"], colors.zinc["800"]],
46
- }),
47
- },
50
+ { backgroundColor: value ? colors.primary : colors.zinc["200"] },
48
51
  ]}
49
52
  >
50
53
  <Animated.View
@@ -62,6 +65,8 @@ export function Switch(props: SwitchProps) {
62
65
  },
63
66
  ]}
64
67
  >
68
+ {/* The tick rides the WHITE thumb, not the track, so it stays neutral
69
+ ink — theming it would put the brand on top of the brand. */}
65
70
  {value && <Icon name="check" size={18} color={colors.zinc["900"]} />}
66
71
  </Animated.View>
67
72
  </Animated.View>