@lotics/ui 43.4.0 → 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 (68) 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/metric.tsx +12 -18
  44. package/src/number_input.tsx +2 -2
  45. package/src/option_picker.tsx +57 -0
  46. package/src/picker.tsx +2 -2
  47. package/src/pressable_row.tsx +15 -5
  48. package/src/progress_bar.tsx +1 -1
  49. package/src/radio_picker.tsx +2 -1
  50. package/src/search_input.tsx +12 -11
  51. package/src/sort_header.tsx +7 -3
  52. package/src/stacked_progress_bar.tsx +1 -1
  53. package/src/step_progress.tsx +5 -2
  54. package/src/switch.tsx +11 -6
  55. package/src/table.tsx +148 -16
  56. package/src/table_fit.ts +12 -0
  57. package/src/tabs.tsx +13 -1
  58. package/src/text.css +50 -18
  59. package/src/text.tsx +37 -40
  60. package/src/text_input_field.tsx +2 -2
  61. package/src/text_utils.ts +48 -6
  62. package/src/theme.ts +13 -0
  63. package/src/theme.web.ts +49 -0
  64. package/src/theme_vars.ts +113 -0
  65. package/src/type_ramp.ts +100 -0
  66. package/src/theme.tsx +0 -24
  67. package/src/theme.web.tsx +0 -79
  68. package/src/theme_context.ts +0 -107
@@ -0,0 +1,66 @@
1
+ import type { ImageContentFit, ImageSource } from "expo-image";
2
+ import type { StyleProp, ViewStyle, ImageStyle } from "react-native";
3
+ import { CONTROL_RADIUS } from "./control_surface";
4
+ import type { AvatarSize } from "./avatar_size";
5
+
6
+ /**
7
+ * THE prop surface both `Avatar` variants implement — the native `avatar.tsx`
8
+ * and the web `avatar.web.tsx`.
9
+ *
10
+ * It lives in a third module for the same reason `color_tokens` does: neither
11
+ * variant may own the contract, because only one of them is ever compiled. TypeScript
12
+ * resolves `./avatar` to the NATIVE file, so a prop added there typechecks
13
+ * everywhere while the web build — the one every custom-code app actually runs —
14
+ * silently ignores it. Nothing fails: no error, no warning, and the call site
15
+ * reads as correct. That is exactly how `shape` was added, verified against the
16
+ * types, and rendered as a circle.
17
+ *
18
+ * A shared interface cannot fix which FILE renders, but it does make the two
19
+ * implementations answer to one declaration, so a prop that exists in the type
20
+ * has a compiler-visible home in both. The behavioural half is `avatarShapeStyle`
21
+ * below: derived once here rather than written twice.
22
+ */
23
+ export interface AvatarProps {
24
+ /** A rung on the shared avatar scale — never a pixel count. See
25
+ * [`avatar_size`](./avatar_size.ts) for why the number was removed. */
26
+ size?: AvatarSize;
27
+ /**
28
+ * WHAT KIND OF THING this identifies — a person (`circle`, the default) or an
29
+ * organization (`square`, a rounded rect at `CONTROL_RADIUS`).
30
+ *
31
+ * It is a real axis, not decoration. A register of companies drawn with the
32
+ * same disc as a register of people asserts they are the same kind of entity,
33
+ * and the reader has nothing but the column header to correct it — so the mark
34
+ * that exists to make a row identifiable at a glance is the one element
35
+ * actively misleading them. The circle/square split is the convention every
36
+ * product carrying both uses, and it costs no colour and no space.
37
+ *
38
+ * Same component rather than a second one, per the kit's mode-is-a-variant
39
+ * rule: the scale, the initials rule, the accent ground, the image path and the
40
+ * a11y contract are all identical, and forking would give an organization its
41
+ * own drifting copy of every one of them.
42
+ */
43
+ shape?: "circle" | "square";
44
+ source?: ImageSource;
45
+ name?: string;
46
+ style?: StyleProp<ViewStyle | ImageStyle>;
47
+ contentFit?: ImageContentFit;
48
+ /**
49
+ * When true, the avatar announces its `name` to assistive tech. Default
50
+ * false because avatars almost always appear adjacent to the name text —
51
+ * announcing the image as well would double-read. Pass `announce` when the
52
+ * avatar is standalone (with no visible name nearby).
53
+ */
54
+ announce?: boolean;
55
+ }
56
+
57
+ /**
58
+ * The radius override a `shape` implies, or `null` for the default disc.
59
+ *
60
+ * A square mark takes the SAME radius as every other control-sized box on the
61
+ * page rather than a number picked for this component, so an org mark sitting
62
+ * beside a button, a chip or an input corners identically instead of nearly so.
63
+ */
64
+ export function avatarShapeStyle(shape: AvatarProps["shape"]): { borderRadius: number } | null {
65
+ return shape === "square" ? { borderRadius: CONTROL_RADIUS } : null;
66
+ }
@@ -0,0 +1,79 @@
1
+ import { colors } from "./colors";
2
+
3
+ /**
4
+ * THE IDENTITY PALETTE — the ground an initials avatar is drawn on, derived from
5
+ * the name it shows.
6
+ *
7
+ * Every disc used to be `colors.accent`, on the argument that an avatar is "the
8
+ * one place a member LOOKS like the product". That argument holds for ONE avatar
9
+ * in a header. It fails in the place avatars actually live: a register of twenty
10
+ * people renders twenty identical circles, so the only thing distinguishing one
11
+ * person from another is two letters at 14px — and the largest, most colourful
12
+ * element in the row carries no information at all. A mark whose whole job is
13
+ * making a row identifiable at a glance should not be the one part of it that is
14
+ * the same on every row.
15
+ *
16
+ * Handing the hue to the NAME makes the colour carry identity too: the same
17
+ * person is the same colour on every screen, and a reader learns "Nguyễn Vi is
18
+ * the violet one" without being told. The brand did not lose anything — it moved
19
+ * to the chrome it belongs in (the active tab, the selection wash, the primary
20
+ * action), which is shared across apps and learned once.
21
+ *
22
+ * Contrast is why the set is CURATED rather than "every family":
23
+ * - every entry is AA against white text at the shade named here, which the warm
24
+ * and green families are NOT at 600 (amber-600 measures ~2.9:1) — so those take
25
+ * 700 or stay out;
26
+ * - `red` and `amber` are excluded entirely. They mean danger and waiting
27
+ * everywhere else in this system, and a person is not a warning. A palette that
28
+ * can paint someone red makes every red on the screen ambiguous.
29
+ *
30
+ * The ORDER is interleaved, not spectral, and that is load-bearing. Listed by
31
+ * hue — blue, indigo, violet, purple, fuchsia, pink — six consecutive entries are
32
+ * six shades of the same purple, so any two names hashing to adjacent buckets get
33
+ * near-identical discs. On a real register that is what happened: ten contacts
34
+ * produced six marks in the violet-to-magenta range and the column read as one
35
+ * colour again. Adjacent indices are now far apart in hue, so near-collisions
36
+ * still look distinct.
37
+ */
38
+ const IDENTITY_TONES: readonly string[] = [
39
+ colors.blue["600"],
40
+ colors.orange["700"],
41
+ colors.violet["600"],
42
+ colors.emerald["700"],
43
+ colors.fuchsia["700"],
44
+ colors.teal["700"],
45
+ colors.indigo["600"],
46
+ colors.pink["600"],
47
+ colors.cyan["700"],
48
+ colors.purple["600"],
49
+ ];
50
+
51
+ /**
52
+ * The ground for `name`'s initials — stable for a given string, so one person
53
+ * keeps one colour across every screen and every session.
54
+ *
55
+ * The hash MIXES rather than sums, which fixes ANAGRAMS specifically: a plain
56
+ * character sum gives `Hai Dang` and `Dang Hai` the same disc, and reorderings
57
+ * are common in names (family name first or last, with or without a middle
58
+ * name). Multiplying by 31 per character makes position count; `|0` keeps it in
59
+ * int32 so the result is identical on every engine.
60
+ *
61
+ * It is NOT what fixed the clustering seen on a real register — the sum was
62
+ * already producing a healthy spread of buckets there, and the interleaved order
63
+ * above is what made that spread visible. Do not credit the hash for it.
64
+ *
65
+ * Whatever the arithmetic, this value is not STORED anywhere: it is recomputed on
66
+ * every render on every client, and the only thing making one person one colour
67
+ * is that the function is pure and never changes. Editing the hash or reordering
68
+ * `IDENTITY_TONES` repaints every person in the product.
69
+ *
70
+ * An empty or missing name falls to the first entry rather than throwing —
71
+ * `Avatar` already substitutes a locale placeholder, and a nameless avatar is a
72
+ * real state (a member still loading) that must render.
73
+ */
74
+ export function avatarTone(name: string | undefined): string {
75
+ const s = name ?? "";
76
+ let h = 0;
77
+ for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
78
+ return IDENTITY_TONES[Math.abs(h) % IDENTITY_TONES.length];
79
+ }
package/src/button.tsx CHANGED
@@ -165,9 +165,22 @@ export function Button(props: ButtonProps) {
165
165
  // Subtle depth on the primary: a soft drop shadow lifts it off the
166
166
  // surface and a whisper of top highlight + a gentle vertical shade (the
167
167
  // app-icon technique) catch the light — premium, not skeuomorphic. The
168
- // lift stays on hover/press; only the gradient drops so the wash shows.
168
+ // lift stays on hover/press; the rest gradient is replaced by a flat
169
+ // overlay so the state reads without losing the themed ground.
169
170
  isPrimary && { boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.18)" },
170
- restPrimary && { backgroundImage: `linear-gradient(180deg, ${colors.zinc["700"]} 0%, ${colors.zinc["900"]} 100%)` },
171
+ // A LIGHT overlay, not a zinc one. This painted
172
+ // `linear-gradient(zinc-700 → zinc-900)` — opaque, hard-coded, and drawn
173
+ // ON TOP of the background — so a themed `colors.primary` was resolved
174
+ // correctly underneath and then completely covered up. The button read
175
+ // near-black in every app, and "primary cannot be themed" looked true
176
+ // while the token had been right all along. A translucent white wash
177
+ // reproduces the same lit-from-above sheen over ANY hue, which also
178
+ // means it cannot go stale against a palette change.
179
+ restPrimary && { backgroundImage: "linear-gradient(180deg, rgba(255,255,255,0.14) 0%, rgba(255,255,255,0) 100%)" },
180
+ // Hover/press ride the SAME ground, lifted by a flat overlay, so the
181
+ // brand stays on screen through every state instead of only at rest.
182
+ isPrimary && pressed && { backgroundImage: `linear-gradient(0deg, ${PRIMARY_OVERLAY.press}, ${PRIMARY_OVERLAY.press})` },
183
+ isPrimary && !pressed && hovered && { backgroundImage: `linear-gradient(0deg, ${PRIMARY_OVERLAY.hover}, ${PRIMARY_OVERLAY.hover})` },
171
184
  styles.button,
172
185
  style,
173
186
  alignSelf && { alignSelf },
@@ -227,6 +240,21 @@ function shade(hex: string, factor: number): string {
227
240
  return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
228
241
  }
229
242
 
243
+ /**
244
+ * The primary button's hover / press lift, as a translucent WHITE overlay.
245
+ *
246
+ * Every state of a themeable control has to derive from its token, and on web a
247
+ * token is a `var()` — a string no colour maths can read, so "the same colour a
248
+ * bit lighter" cannot be computed at the call site. An overlay sidesteps that
249
+ * entirely: it lightens whatever is underneath, so one pair of values works for
250
+ * near-black, cobalt, teal or anything an app sets, and it can never go stale
251
+ * against a palette it does not know about.
252
+ *
253
+ * The direction matches what the hard-coded zinc steps did (zinc-900 → 700 →
254
+ * 600): both states LIFT, and press lifts further than hover.
255
+ */
256
+ const PRIMARY_OVERLAY = { hover: "rgba(255,255,255,0.16)", press: "rgba(255,255,255,0.26)" };
257
+
230
258
  function getButtonBackgroundColor(color?: ButtonColor) {
231
259
  switch (color) {
232
260
  case "primary":
@@ -259,7 +287,10 @@ function getButtonDisabledBackgroundColor(color?: ButtonColor) {
259
287
  function getButtonHoverColor(color?: ButtonColor) {
260
288
  switch (color) {
261
289
  case "primary":
262
- return colors.zinc["700"];
290
+ // The TOKEN, unchanged — the lift is an overlay, not a lighter zinc. See
291
+ // `PRIMARY_OVERLAY`. Returning zinc-700 here meant a themed button was the
292
+ // brand at rest and grey the instant a pointer touched it.
293
+ return colors.primary;
263
294
  case "secondary":
264
295
  return colors.zinc["200"];
265
296
  case "danger":
@@ -274,7 +305,8 @@ function getButtonHoverColor(color?: ButtonColor) {
274
305
  function getButtonPressedColor(color?: ButtonColor) {
275
306
  switch (color) {
276
307
  case "primary":
277
- return colors.zinc["600"];
308
+ // The TOKEN, unchanged — see `getButtonHoverColor`.
309
+ return colors.primary;
278
310
  case "secondary":
279
311
  return colors.zinc["300"];
280
312
  case "danger":
package/src/checkbox.tsx CHANGED
@@ -25,7 +25,10 @@ export function Checkbox(props: CheckboxProps) {
25
25
  alignItems: "center",
26
26
  borderWidth: 1,
27
27
  borderColor: colors.border,
28
- backgroundColor: filled ? colors.zinc["800"] : colors.background,
28
+ // The ticked fill is the app's PRIMARY colour, not a fixed near-black.
29
+ // "on" is the same claim the one filled button makes, so it reads from
30
+ // the same token and a themed app gets a branded tick for free.
31
+ backgroundColor: filled ? colors.primary : colors.background,
29
32
  borderRadius: 6,
30
33
  }}
31
34
  >
@@ -8,7 +8,7 @@ import { composeHandler, useFocusRing } from "./use_focus_ring";
8
8
  import { useHover } from "./use_hover";
9
9
  import { CONTROL_RADIUS, CURSOR_DEFAULT, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
10
10
  import { useAutoGrowHeight } from "./use_auto_grow_height";
11
- import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
11
+ import { fontFamilyMedium, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
12
12
  import { useLoticsLocale } from "./locale";
13
13
 
14
14
  // A typed custom answer must read exactly like a picked option's LABEL (`Text
@@ -16,7 +16,7 @@ import { useLoticsLocale } from "./locale";
16
16
  // 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
17
17
  // size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
18
18
  // and colour to the label, which is where an unstyled input actually diverges from it.
19
- const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: -0.4, color: colors.zinc[900] } as const;
19
+ const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: INPUT_LETTER_SPACING, color: colors.zinc[900] } as const;
20
20
 
21
21
  export interface ChoiceOption {
22
22
  label: string;
@@ -73,7 +73,9 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
73
73
  ) : null}
74
74
  </View>
75
75
  <View style={{ width: 17, alignItems: "center" }}>
76
- {selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
76
+ {/* The tick is a "this is on" mark like a checkbox's fill or a switch's
77
+ track, so it reads the primary token rather than a fixed near-black. */}
78
+ {selected ? <Icon name="check" size={17} color={colors.primary} /> : null}
77
79
  </View>
78
80
  </Pressable>
79
81
  );
@@ -315,9 +315,8 @@ export const colors = {
315
315
  /** A RAISED surface — Card, Drawer, Modal, the things that sit on the page. */
316
316
  background: palette.white,
317
317
  /**
318
- * The one brand hue, and its reach is narrower than the word suggests: it
319
- * paints IDENTITY marks today the avatar's initials disc, the one place a
320
- * person is rendered as a coloured shape, on every register in every app.
318
+ * The one brand hue. It paints "WHERE YOU ARE" the active tab's underline,
319
+ * the selected row's ground, an active filter and IDENTITY chrome.
321
320
  *
322
321
  * It deliberately does NOT paint interaction chrome. The focus ring is
323
322
  * `zinc[900]` and the primary action is near-black because those are the KIT's
@@ -328,14 +327,13 @@ export const colors = {
328
327
  */
329
328
  accent: palette.blue["600"],
330
329
  /**
331
- * The PRIMARY action's fill — the one filled button on a surface.
330
+ * The PRIMARY action's fill — the one filled button on a surface, and every
331
+ * other "this is on": a ticked checkbox, a switch's track, a selected day.
332
332
  *
333
- * Separate from `accent` because their defaults differ and so do their jobs: a
334
- * brand can own the disc a person is drawn as without owning the shape of a
335
- * commit, and near-black is a deliberate neutral that works under any brand.
336
- * Folding them would force an app that themes its avatars to also repaint every
337
- * CTA, and would turn this near-black into blue for every app that themes
338
- * nothing.
333
+ * Separate from `accent` because their jobs differ: an app can own the hue that
334
+ * says WHERE YOU ARE without owning the shape of a COMMIT, and a near-black
335
+ * commit reads under any brand. Folding them would force an app that themes its
336
+ * accent to also repaint every CTA.
339
337
  */
340
338
  primary: palette.zinc["900"],
341
339
  /**
@@ -344,13 +342,16 @@ export const colors = {
344
342
  * It exists as its own token because it cannot be derived where it is used:
345
343
  * `withAlpha`/`tint` do string surgery on an `rgba()`, and on web `accent` is a
346
344
  * `var()`, so asking for "accent at 7%" at the call site produces garbage no
347
- * type would catch. `LoticsThemeProvider` computes it instead, from the literal
345
+ * type would catch. `applyLoticsTheme` computes it instead, from the literal
348
346
  * hex the app handed it — derivation happens where the literal still exists.
349
347
  *
350
- * The default is the neutral wash these surfaces already wore, so an app that
351
- * themes nothing is pixel-identical.
348
+ * The default is DERIVED FROM `accent`'s default, not picked separately: the
349
+ * accent is blue-600, so the wash is blue-50. It was zinc-100, which meant the
350
+ * two halves of one token pair disagreed and every unthemed app got a
351
+ * colourless "you are here"; a wash that contradicts its own accent is not a
352
+ * neutral default, it is a second brand nobody chose.
352
353
  */
353
- accent_wash: palette.zinc["100"],
354
+ accent_wash: palette.blue["50"],
354
355
  shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
355
356
  };
356
357
 
package/src/composer.tsx CHANGED
@@ -3,7 +3,7 @@ import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputP
3
3
  import { IconButton } from "./icon_button";
4
4
  import { colors } from "./colors";
5
5
  import { Text } from "./text";
6
- import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
6
+ import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
7
7
  import { useAutoGrowHeight } from "./use_auto_grow_height";
8
8
  import { useLoticsLocale } from "./locale";
9
9
 
@@ -220,7 +220,7 @@ export function Composer(props: ComposerProps) {
220
220
  wrap into clipped lines inside the pill — one line, ellipsized. */}
221
221
  {emptyText && placeholder ? (
222
222
  <View pointerEvents="none" style={[styles.placeholderOverlay, expanded ? styles.placeholderOverlayExpanded : { paddingHorizontal: 10 }]}>
223
- <Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: -0.4 }}>
223
+ <Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: INPUT_LETTER_SPACING }}>
224
224
  {placeholder}
225
225
  </Text>
226
226
  </View>
@@ -346,6 +346,6 @@ const styles = StyleSheet.create({
346
346
  textInput: {
347
347
  flex: 1,
348
348
  fontFamily: fontFamilyRegular,
349
- letterSpacing: -0.4,
349
+ letterSpacing: INPUT_LETTER_SPACING,
350
350
  },
351
351
  });
@@ -106,12 +106,27 @@ export const FOCUS_RING = `0 0 0 ${FOCUS_RING_WIDTH}px ${colors.zinc[900]}`;
106
106
  */
107
107
  export const CONTROL_TEXT_INSET = 9;
108
108
 
109
- /** A bordered control's pointer-hover edge: the resting `border` darkens to this on
110
- * hover the premium "this is interactive" affordance that complements the focus
111
- * ring (focus = the ring; hover = a darker border). Drive it with `useHover` on a
112
- * TextInput/select, or a Pressable's built-in `hovered` state. Pair with
113
- * `CONTROL_TRANSITION` so the change eases in. */
114
- export const HOVER_BORDER = colors.zinc[400];
109
+ /**
110
+ * THE hover edge for every bordered control the resting `border` darkens to
111
+ * this. Focus is the ring; hover is a darker border; those are the only two, and
112
+ * a control that adds a third signal of its own breaks the pattern for all of
113
+ * them. Drive it with `useHover` on a TextInput/select or a Pressable's built-in
114
+ * `hovered`, and pair it with `CONTROL_TRANSITION` so the change eases in.
115
+ *
116
+ * zinc-500, not zinc-400. At 400 the shift was zinc-200 → zinc-400 on a 1px
117
+ * line — measurably a change and visually almost nothing, which is exactly why
118
+ * `InlineEdit` had grown a background TINT on top to compensate. That left the
119
+ * kit with two hover languages: most inputs moved their border, one also washed
120
+ * its surface, and a reader crossing a form met both. The answer is one signal
121
+ * carried properly rather than a second signal bolted on, so the border does the
122
+ * work everywhere and nothing tints.
123
+ *
124
+ * It stays NEUTRAL rather than taking the accent: hover means "your pointer is
125
+ * here", which is transient and true of whatever you happen to be over, while
126
+ * the accent means "you are here" — a state the screen holds. Painting both in
127
+ * the brand would make the brand the least informative colour on the page.
128
+ */
129
+ export const HOVER_BORDER = colors.zinc[500];
115
130
 
116
131
  /**
117
132
  * The wash a control paints when it rides a HOVERABLE surface — a register row,
@@ -154,9 +169,22 @@ export function chipSurfaceStyle(
154
169
  justifyContent: "center",
155
170
  borderRadius: CONTROL_RADIUS,
156
171
  borderWidth: 1,
157
- borderColor: selected ? colors.zinc[900] : colors.border,
158
- backgroundColor: state.pressed ? colors.zinc[200] : state.hovered ? colors.zinc[100] : colors.white,
159
- boxShadow: selected ? `0 0 0 1px ${colors.zinc[900]}` : undefined,
172
+ // A SELECTED chip needs a GROUND, not a heavier outline. This drew
173
+ // zinc-900 at 2px on a white fill and in a toolbar where every sibling is
174
+ // also a white pill with a 1px border, "which filter is on" came down to a
175
+ // one-pixel colour change that reads as noise at a glance. The accent wash
176
+ // is the same "you are here" ground the active tab and the selected row
177
+ // carry, so one signal means one thing across the product; it also stays
178
+ // clear of the primary button's near-black fill, which a dark chip would
179
+ // have collided with two controls away.
180
+ borderColor: selected ? colors.accent : colors.border,
181
+ backgroundColor: selected
182
+ ? colors.accent_wash
183
+ : state.pressed
184
+ ? colors.zinc[200]
185
+ : state.hovered
186
+ ? colors.zinc[100]
187
+ : colors.white,
160
188
  };
161
189
  }
162
190
 
@@ -13,7 +13,14 @@ export interface CopyButtonProps {
13
13
  * the pack's bare verb. */
14
14
  label?: string;
15
15
  /** `sm` (the default, 24px) sits beside body/sm text — a value in a register
16
- * cell or a detail row. `md` (28px) for a toolbar. */
16
+ * cell or a detail row — because 24 IS the `sm` line box, so the control adds
17
+ * nothing to the line's height. `md` (28px) for a toolbar.
18
+ *
19
+ * On a SMALLER line (an `xs` supporting line, 18px) there is no matching rung,
20
+ * and dropping `sm` on it makes the row 24 — the text then sits 3px lower than
21
+ * the same supporting line one column over. Pin that row's `height` to the
22
+ * text's leading and let this overflow it; see `docs/composition.md`
23
+ * §"The register's own craft" for the rule and `tpl_item_list` for the worked line. */
17
24
  size?: "sm" | "md";
18
25
  disabled?: boolean;
19
26
  testID?: string;
package/src/counter.tsx CHANGED
@@ -56,6 +56,6 @@ export function Counter(props: CounterProps) {
56
56
 
57
57
  const styles = StyleSheet.create({
58
58
  row: { flexDirection: "row", alignItems: "center", gap: 16, alignSelf: "flex-start" },
59
- btn: { width: 32, height: 32, borderWidth: 1, borderColor: colors.zinc[300] },
59
+ btn: { width: 32, height: 32, borderWidth: 1, borderColor: colors.border },
60
60
  value: { minWidth: 56 },
61
61
  });
package/src/data_grid.tsx CHANGED
@@ -150,7 +150,10 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
150
150
  if (!sortable) {
151
151
  return (
152
152
  <View style={[styles.sortLabel, style]}>
153
- <Text size="xs" weight="semibold" color="muted" numberOfLines={1} style={styles.headLabel}>{label}</Text>
153
+ {/* The SAME header treatment as `Table`, which owns the reason —
154
+ `DataGrid` and `Table` are one surface to a reader, so they cannot
155
+ label their columns differently. */}
156
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>{label}</Text>
154
157
  </View>
155
158
  );
156
159
  }
@@ -158,7 +161,8 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
158
161
  const dirText = active ? (sort?.dir === "asc" ? (labels?.ascending ?? ", ascending") : (labels?.descending ?? ", descending")) : "";
159
162
  return (
160
163
  <FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, styles.sortLabelPressable, hovered ? styles.sortLabelHover : null, style]}>
161
- <Text size="xs" weight="semibold" color={active ? "default" : "muted"} numberOfLines={1} style={styles.headLabel}>{label}</Text>
164
+ {/* Active steps up in INK, not weight see `SortHeader`. */}
165
+ <Text size="sm" weight="medium" color={active ? "default" : "muted"} numberOfLines={1}>{label}</Text>
162
166
  {arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
163
167
  </FocusRingPressable>
164
168
  );
@@ -181,7 +185,6 @@ const styles = StyleSheet.create({
181
185
  borderBottomColor: colors.zinc[100],
182
186
  marginBottom: 4,
183
187
  },
184
- headLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
185
188
  // Padded for the hover pill but zero-width in flow (negative margin), so the label
186
189
  // sits on its cell's edge instead of 8px inside it.
187
190
  sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
@@ -832,7 +832,9 @@ const styles = StyleSheet.create({
832
832
  borderRadius: 999,
833
833
  },
834
834
  dayCircleSelected: {
835
- backgroundColor: colors.zinc["800"],
835
+ // The picked day is the calendar's one filled mark — the same claim as a
836
+ // ticked checkbox or the primary button, so it reads the same token.
837
+ backgroundColor: colors.primary,
836
838
  },
837
839
  dayCircleHovered: {
838
840
  backgroundColor: colors.zinc["50"],
@@ -843,7 +845,7 @@ const styles = StyleSheet.create({
843
845
  width: 4,
844
846
  height: 4,
845
847
  borderRadius: 2,
846
- backgroundColor: colors.zinc["800"],
848
+ backgroundColor: colors.primary,
847
849
  },
848
850
  todayDotInverted: {
849
851
  backgroundColor: colors.white,
@@ -132,7 +132,11 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
132
132
  paddingVertical: 9,
133
133
  paddingHorizontal: 12,
134
134
  borderWidth: 1,
135
- borderColor: state.hovered ? HOVER_BORDER : colors.zinc[300],
135
+ // `colors.border` like every other control — this rested a step
136
+ // heavier for no stated reason, which is the same outlier
137
+ // `SearchInput` was: one control in a band with a darker edge reads
138
+ // as an accident rather than as emphasis.
139
+ borderColor: state.hovered ? HOVER_BORDER : colors.border,
136
140
  borderRadius: CONTROL_RADIUS,
137
141
  backgroundColor: colors.white,
138
142
  ...CONTROL_TRANSITION,
@@ -11,7 +11,7 @@ import {
11
11
  } from "react-native";
12
12
  import { colors } from "./colors";
13
13
  import { Text } from "./text";
14
- import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
14
+ import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
15
15
  import {
16
16
  SegmentBuffer,
17
17
  SegmentLabels,
@@ -449,7 +449,7 @@ const styles = StyleSheet.create({
449
449
  segment: {
450
450
  ...getInputTextStyle(),
451
451
  fontFamily: fontFamilyRegular,
452
- letterSpacing: -0.4,
452
+ letterSpacing: INPUT_LETTER_SPACING,
453
453
  color: colors.zinc["900"],
454
454
  textAlign: "center",
455
455
  paddingVertical: 0,
@@ -112,7 +112,7 @@ const styles = StyleSheet.create({
112
112
  // box-shadow/background — zero layout impact, so nothing shifts mid-drag.
113
113
  dragging: {
114
114
  borderRadius: 12,
115
- backgroundColor: colors.blue[50],
116
- boxShadow: `0 0 0 1.5px ${colors.blue[500]}, 0 0 0 6px ${colors.blue[50]}`,
115
+ backgroundColor: colors.accent_wash,
116
+ boxShadow: `0 0 0 1.5px ${colors.accent}, 0 0 0 6px ${colors.accent_wash}`,
117
117
  },
118
118
  });
@@ -99,9 +99,9 @@ export function FileDropzone(props: FileDropzoneProps) {
99
99
  ]}
100
100
  >
101
101
  <View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
102
- <Icon name="upload" size={20} color={dragging ? colors.blue[600] : colors.zinc[500]} />
102
+ <Icon name="upload" size={20} color={dragging ? colors.accent : colors.zinc[500]} />
103
103
  </View>
104
- <Text size="sm" weight="medium" style={dragging ? { color: colors.blue[700] } : undefined}>
104
+ <Text size="sm" weight="medium" style={dragging ? { color: colors.accent } : undefined}>
105
105
  {dragging ? dropLabel : label}
106
106
  </Text>
107
107
  {!dragging && hint ? (
@@ -133,10 +133,13 @@ const styles = StyleSheet.create({
133
133
  backgroundColor: colors.zinc[100],
134
134
  },
135
135
  // The drop invitation: accent border + tinted well — unmistakably "this is
136
- // where your files land".
136
+ // where your files land". THE TOKEN, not a literal blue: the comment already
137
+ // said "accent" while the value was a fixed hue, so a themed app's most
138
+ // conspicuous state — a whole region lighting up under a dragged file — was
139
+ // the one that stayed brand blue.
137
140
  zoneDragging: {
138
- borderColor: colors.blue[500],
139
- backgroundColor: colors.blue[50],
141
+ borderColor: colors.accent,
142
+ backgroundColor: colors.accent_wash,
140
143
  },
141
144
  zoneDisabled: {
142
145
  opacity: 0.5,
@@ -149,7 +152,10 @@ const styles = StyleSheet.create({
149
152
  justifyContent: "center",
150
153
  backgroundColor: colors.zinc[100],
151
154
  },
155
+ // WHITE on the wash rather than a deeper tint of it. The deeper tint has no
156
+ // token — `withAlpha` cannot derive one from a themed `var()` — and a lifted
157
+ // white disc reads as raised at every hue, which the two-tone did only at one.
152
158
  iconWellDragging: {
153
- backgroundColor: colors.blue[100],
159
+ backgroundColor: colors.white,
154
160
  },
155
161
  });
package/src/file_rows.tsx CHANGED
@@ -43,6 +43,21 @@ export interface FileRowsProps {
43
43
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
44
44
  /** Translated chrome — Download / Remove / Open-external / gallery + confirm. */
45
45
  labels?: Partial<GalleryLabels>;
46
+ /**
47
+ * WHAT PRESSING A ROW DOES. Default `"preview"`.
48
+ *
49
+ * Not a switch for turning the gallery off — a statement about what the files
50
+ * ARE. A scan, a photo, a screenshot is something you LOOK at and move on
51
+ * from, and a full-screen preview is the whole interaction. A contract, a
52
+ * recording, a spreadsheet is something you WORK on, and a lightbox inside a
53
+ * drawer is a dead end: you cannot sign it, edit it, or send it from there, so
54
+ * every press costs a dismiss before the real act.
55
+ *
56
+ * `"open"` requires `onOpenExternal` — without a way out, the row would be a
57
+ * door onto nothing — and it stops mounting the gallery at all, so a surface
58
+ * that never previews pays nothing for the engines it does not use.
59
+ */
60
+ press?: "preview" | "open";
46
61
  /** Gap between rows. Default 8. */
47
62
  gap?: number;
48
63
  /** Credentials mode for the gallery's preview fetches — `"include"` for the
@@ -56,8 +71,11 @@ export interface FileRowsProps {
56
71
  * record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
57
72
  * for bare square tiles use `FileThumbnailGrid`.
58
73
  */
59
- export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials }: FileRowsProps) {
74
+ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials, press = "preview" }: FileRowsProps) {
60
75
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
76
+ // `open` without a way out would make every row a door onto nothing, so the
77
+ // preview stays the fallback rather than the press silently doing nothing.
78
+ const opens = press === "open" && onOpenExternal !== undefined;
61
79
  const l = { ...useLoticsLocale().gallery, ...labels };
62
80
 
63
81
  if (files.length === 0) return null;
@@ -96,11 +114,12 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
96
114
  name={file.filename}
97
115
  meta={meta ? meta(file) : undefined}
98
116
  mimeType={file.mimeType}
99
- onPress={() => setActiveIndex(index)}
117
+ onPress={opens ? () => onOpenExternal(file) : () => setActiveIndex(index)}
100
118
  trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
101
119
  />
102
120
  );
103
121
  })}
122
+ {opens ? null : (
104
123
  <FileGalleryModal
105
124
  files={files}
106
125
  activeIndex={activeIndex}
@@ -115,6 +134,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
115
134
  onError={onError}
116
135
  labels={labels}
117
136
  />
137
+ )}
118
138
  </View>
119
139
  );
120
140
  }