@lotics/ui 30.1.0 → 31.0.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.
package/MIGRATION.md CHANGED
@@ -4,6 +4,47 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 31.0.0 — avatar `size` is a rung, not a pixel count
8
+
9
+ `Avatar`, `MemberChip` and `GroupAvatar` no longer take `size?: number`. They take
10
+ `size?: AvatarSize` — `"sm" | "md" | "lg" | "xl"` = **24 / 36 / 48 / 72** — defaulting to `md`.
11
+
12
+ Map an EXPLICIT size by the density it was reaching for, not by nearest pixel:
13
+
14
+ | was | now |
15
+ | --- | --- |
16
+ | ≤ 24 (18, 20, 22, 24) | `"sm"` |
17
+ | 28, 32, 36 | `"md"` |
18
+ | 40, 48 | `"lg"` |
19
+ | 64, 80 | `"xl"` |
20
+
21
+ **The DEFAULT is now `md` (36) on all three components**, which is a separate move from that
22
+ table and goes in different directions: `Avatar` grows (32 → 36), `MemberChip` grows (28 → 36),
23
+ and **`GroupAvatar` shrinks (40 → 36)**. If a `GroupAvatar` was relying on its default to sit at
24
+ 40 beside something else, pass `"lg"`. One default is the point — a member and a group in one
25
+ list had no business being different sizes.
26
+
27
+ Need an avatar's pixels for a sibling box — a stack's overlap, a dashed placeholder? Import
28
+ `AVATAR_PX` from `@lotics/ui/avatar_size` and index it by the rung, so the two cannot drift.
29
+
30
+ **Three rendering changes come with it, and none is opt-in.**
31
+
32
+ Initials are now the first and **last** word of the name rather than the first two: "Vũ Thị
33
+ Lan" renders VL where it rendered VT. A Vietnamese name is họ + đệm + tên, so the first two
34
+ words are the parts a whole office shares — on a real 29-person roster the old rule left 11
35
+ people indistinguishable and this one leaves 5. Two-word names are unaffected ("John Smith" is
36
+ JS either way), and it matches the conventional English rendering ("Mary Jane Watson" → MW).
37
+ `GroupAvatar` follows the same rule now: it showed one letter at every size and its neighbours
38
+ in the same list showed two.
39
+
40
+ The one-initial cutoff moved from "≤ 32px" to "`sm` only". Anything you map to `md` or above
41
+ now shows two letters where it showed one — which is the point: every call site in the
42
+ register apps sat under the old threshold, so the product only ever rendered a single letter.
43
+ If a surface genuinely wants one letter, that surface is `sm`.
44
+
45
+ The initials' TYPE SIZE now follows the rung instead of being a fixed `xs`. Most visible on the
46
+ large rungs: an avatar that was 80px wore 12px letters and looked empty; at `xl` it wears 28px.
47
+
7
48
  ## 30.0.0 — a washed row bleeds; content sits on the container's edge
8
49
 
9
50
  No API changed. Every register, table and choice list **moves its content 20px
package/docs/catalog.md CHANGED
@@ -76,6 +76,20 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
76
76
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
77
77
  renders these per option.)
78
78
 
79
+ **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 36 / 48 / 72
80
+ (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
81
+ The number was removed because the same concept had drifted to eleven different values across
82
+ the product and its apps, so one person looked different on every screen. Pick by density:
83
+ `sm` for a dense row or inline cell, `md` for a register or list (the default), `lg` for a
84
+ prominent row or the account menu, `xl` for a profile's own header.
85
+
86
+ **`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
87
+ That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
88
+ is rarely unique (nine people can share "N"), so a register that must identify someone wants
89
+ `md`. Initials are the first and **last** word of the name — "Vũ Thị Lan" → VL, "Mary Jane
90
+ Watson" → MW — because a Vietnamese name's first two words are the family and middle names
91
+ that a whole office shares.
92
+
79
93
  ### A select-field value
80
94
 
81
95
  `OptionBadge` (a stored `select` value as its CONFIGURED colored badge) — never hand-map
@@ -425,12 +439,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
425
439
  `themeColor` palette token (unknown → neutral zinc) holding any Lucide icon by runtime
426
440
  name via `DynamicIcon`; `size` sm|md. One render wherever an app shows — launcher, list,
427
441
  picker, settings.
428
- - **`avatar`** — `Avatar`: image-or-initial person avatar (`source`/`name`/`size`;
429
- `announce` when standalone — decorative by default because the name text usually sits
430
- beside it).
431
- - **`group_avatar`** — `GroupAvatar`: the first letter of `name` in a zinc rounded square
432
- (`size`, default 40); the avatar for image-less entities groups, organizations. A person
433
- `Avatar`/`MemberChip`.
442
+ - **`avatar`** — `Avatar`: image-or-initial person avatar (`source`/`name`/`size` — a rung,
443
+ see above; `announce` when standalone — decorative by default because the name text usually
444
+ sits beside it).
445
+ - **`avatar_size`** — the shared avatar scale: `AvatarSize` (`sm|md|lg|xl`), `AVATAR_PX`,
446
+ `AVATAR_TEXT`, `avatarInitials`. Import `AVATAR_PX` when a sibling box must match an
447
+ avatar's pixels (a stack's overlap, a dashed placeholder) so the two cannot drift.
448
+ - **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
449
+ same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
450
+ image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
434
451
  - **`wave_avatar`** — `WaveAvatar`: decorative animated waveform avatar (voice/audit
435
452
  history); animates on web, renders a static fallback on native.
436
453
  - **`member_chip`** — `MemberChip`: avatar + name; the universal person render.
@@ -402,6 +402,17 @@ running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagi
402
402
  Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
403
403
  a fixed `width`, so amount/status columns don't jitter.
404
404
 
405
+ **Cells whose LINE COUNTS differ need `align="top"`.** Cells are centred by default, which is
406
+ right while every cell is one line. The moment they are not — an identity cell carrying a
407
+ supporting line (2), a status cell with a bar over a label (3), a member chip (1) — centring four
408
+ different heights puts their four FIRST lines at four different heights, and the eye steps down
409
+ and back up reading across one row. Measured on a live register: a 12px spread, closed to 2px by
410
+ `align="top"`, which makes cells share a top edge so first lines land on one baseline. It governs
411
+ the CELLS only; the ordinal, the leading checkbox and the trailing ⋯ stay centred, being one line
412
+ by construction. Note a cell whose first element is a BAR or an image still starts its text below
413
+ that element — top-aligning the cell does not top-align the text inside it, so put the line you
414
+ want on the baseline first.
415
+
405
416
  ## Row actions are always-visible siblings — never hover-revealed, never nested
406
417
 
407
418
  A pressable row = a role-less surface (the hover/press wash covers the whole row) + an accessible
@@ -141,7 +141,7 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
141
141
  >
142
142
  <TableCell>
143
143
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
144
- <Avatar name={nv.ten} size={36} />
144
+ <Avatar name={nv.ten} size="md" />
145
145
  <View style={{ flex: 1, gap: 2, minWidth: 0 }}>
146
146
  <Text weight="medium" numberOfLines={1}>{nv.ten}</Text>
147
147
  <Text size="sm" color="zinc-500" numberOfLines={1}>{nv.chucVu}</Text>
@@ -207,7 +207,7 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
207
207
  <>
208
208
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
209
209
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
210
- <Avatar name={nv.ten} size={40} />
210
+ <Avatar name={nv.ten} size="lg" />
211
211
  <View style={{ flex: 1, gap: 2 }}>
212
212
  <Text size="sm" color="muted">{nv.chucVu}</Text>
213
213
  </View>
@@ -257,7 +257,7 @@ function KhachPeek({ kh }: { kh: (typeof KHACH_DAN_DAU)[number] }) {
257
257
  return (
258
258
  <View style={{ gap: 10 }}>
259
259
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
260
- <Avatar name={kh.ten} size={32} />
260
+ <Avatar name={kh.ten} size="md" />
261
261
  <View style={{ gap: 1, flex: 1 }}>
262
262
  <Text size="sm" weight="semibold">{kh.ten}</Text>
263
263
  <Text size="xs" color="muted">{kh.nganh}</Text>
@@ -500,7 +500,7 @@ export function TplDashboard() {
500
500
  <View key={kh.ten}>
501
501
  {i > 0 ? <Divider /> : null}
502
502
  <View style={{ paddingHorizontal: 20, paddingVertical: 12, flexDirection: "row", alignItems: "center", gap: 12 }}>
503
- <Avatar name={kh.ten} size={32} />
503
+ <Avatar name={kh.ten} size="md" />
504
504
  <View style={{ flex: 1, gap: 0, alignItems: "flex-start" }}>
505
505
  <Peek accessibilityLabel={`Customer profile for ${kh.ten}`} content={<KhachPeek kh={kh} />}>
506
506
  <Text size="sm" weight="medium" numberOfLines={1}>{kh.ten}</Text>
@@ -283,7 +283,7 @@ export function TplShifts() {
283
283
  onPress={() => setOpenSlot(slotId(su.day, su.shift))}
284
284
  style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52 }}
285
285
  >
286
- <Avatar name={su.member} size={28} />
286
+ <Avatar name={su.member} size="md" />
287
287
  <View style={{ flex: 1, gap: 0 }}>
288
288
  <Text size="sm" numberOfLines={1}>{su.member}</Text>
289
289
  <Text size="xs" color="muted">{slotLabel(su.day, su.shift)}</Text>
@@ -344,7 +344,7 @@ export function TplShifts() {
344
344
  ) : (
345
345
  openAssigned.map((m) => (
346
346
  <View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
347
- <Avatar name={m} size={28} />
347
+ <Avatar name={m} size="md" />
348
348
  <Text size="sm" style={{ flex: 1 }}>{m}</Text>
349
349
  <Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
350
350
  <ActionMenu
@@ -362,7 +362,7 @@ export function TplShifts() {
362
362
  const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
363
363
  return (
364
364
  <View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
365
- <Avatar name={su.member} size={28} />
365
+ <Avatar name={su.member} size="md" />
366
366
  <Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
367
367
  <Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
368
368
  {hoursAfter > WEEK_LIMIT ? (
@@ -398,7 +398,7 @@ export function TplShifts() {
398
398
  return (
399
399
  <MenuListItem
400
400
  key={m}
401
- icon={<Avatar name={m} size={28} />}
401
+ icon={<Avatar name={m} size="md" />}
402
402
  title={m}
403
403
  description={
404
404
  after > WEEK_LIMIT
@@ -322,7 +322,7 @@ export function TplTaskBoard() {
322
322
  out = STATUS_ORDER.map((s) => ({ key: s, header: <OptionBadge value={STATUS[s]} variant="dot" />, items: pool.filter((t) => t.status === s) }));
323
323
  } else if (groupBy === "assignee") {
324
324
  out = [
325
- ...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size={22} />, items: pool.filter((t) => t.ownerId === m.id) })),
325
+ ...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size="sm" />, items: pool.filter((t) => t.ownerId === m.id) })),
326
326
  { key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((t) => !t.ownerId) },
327
327
  ];
328
328
  } else {
@@ -348,11 +348,11 @@ export function TplTaskBoard() {
348
348
  </FilterChip>
349
349
  <FilterChip
350
350
  label="Assignee"
351
- summary={assigneeFilter.length > 0 ? <View style={styles.summary}>{assigneeFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size={20} />)}</View> : undefined}
351
+ summary={assigneeFilter.length > 0 ? <View style={styles.summary}>{assigneeFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size="sm" />)}</View> : undefined}
352
352
  onClear={() => setAssigneeFilter([])}
353
353
  clearLabel="Clear assignee filter"
354
354
  >
355
- <OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={assigneeFilter} onValueChange={setAssigneeFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size={22} />} />
355
+ <OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={assigneeFilter} onValueChange={setAssigneeFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size="sm" />} />
356
356
  </FilterChip>
357
357
  <FilterChip
358
358
  label="Tag"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "30.1.0",
3
+ "version": "31.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -257,6 +257,7 @@
257
257
  "./text_utils": "./src/text_utils.ts",
258
258
  "./column_filter": "./src/column_filter.tsx",
259
259
  "./chip_group": "./src/chip_group.tsx",
260
+ "./avatar_size": "./src/avatar_size.ts",
260
261
  "./diff_value": "./src/diff_value.tsx",
261
262
  "./diff_mark": "./src/diff_mark.tsx",
262
263
  "./use_change_set": "./src/use_change_set.ts"
package/src/avatar.tsx CHANGED
@@ -4,9 +4,12 @@ import { View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
6
  import { useLoticsLocale } from "./locale";
7
+ import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
7
8
 
8
9
  interface AvatarProps {
9
- size?: number;
10
+ /** A rung on the shared avatar scale — never a pixel count. See
11
+ * [`avatar_size`](./avatar_size.ts) for why the number was removed. */
12
+ size?: AvatarSize;
10
13
  source?: ImageSource;
11
14
  name?: string;
12
15
  style?: StyleProp<ViewStyle | ImageStyle>;
@@ -22,8 +25,9 @@ interface AvatarProps {
22
25
 
23
26
  export function Avatar(props: AvatarProps) {
24
27
  const locale = useLoticsLocale();
25
- const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
28
+ const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
26
29
  const decorative = !announce;
30
+ const px = AVATAR_PX[size];
27
31
 
28
32
  if (!source || !source.uri) {
29
33
  return (
@@ -35,7 +39,7 @@ export function Avatar(props: AvatarProps) {
35
39
  aria-hidden={decorative || undefined}
36
40
  style={[
37
41
  styles.base,
38
- { backgroundColor: colors.blue["600"], width: size, height: size },
42
+ { backgroundColor: colors.blue["600"], width: px, height: px },
39
43
  style,
40
44
  ]}
41
45
  >
@@ -43,14 +47,14 @@ export function Avatar(props: AvatarProps) {
43
47
  on the container so the SR does not read "HM" in addition. */}
44
48
  <Text
45
49
  userSelect="none"
46
- size="xs"
50
+ size={AVATAR_TEXT[size]}
47
51
  weight="medium"
48
52
  color="inverted"
49
53
  accessibilityElementsHidden
50
54
  importantForAccessibility="no-hide-descendants"
51
55
  aria-hidden
52
56
  >
53
- {getInitials(name, size)}
57
+ {avatarInitials(name, size)}
54
58
  </Text>
55
59
  </View>
56
60
  );
@@ -61,7 +65,7 @@ export function Avatar(props: AvatarProps) {
61
65
  alt={decorative ? "" : name}
62
66
  accessibilityElementsHidden={decorative}
63
67
  importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
64
- style={[styles.base, { width: size, height: size }, style as ImageStyle]}
68
+ style={[styles.base, { width: px, height: px }, style as ImageStyle]}
65
69
  source={source}
66
70
  contentFit={contentFit}
67
71
  cachePolicy="memory-disk"
@@ -69,23 +73,6 @@ export function Avatar(props: AvatarProps) {
69
73
  );
70
74
  }
71
75
 
72
- function getInitials(name: string, size?: number): string {
73
- let initials = 2;
74
-
75
- if (size && size <= 32) {
76
- initials = 1;
77
-
78
- if (name.length <= 2) {
79
- return name;
80
- }
81
- }
82
-
83
- return name
84
- .split(" ")
85
- .map((c) => c.charAt(0).toUpperCase())
86
- .slice(0, initials)
87
- .join("");
88
- }
89
76
 
90
77
  const styles = StyleSheet.create({
91
78
  base: {
@@ -3,9 +3,11 @@ import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { useLoticsLocale } from "./locale";
6
+ import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
6
7
 
7
8
  interface AvatarProps {
8
- size?: number;
9
+ /** A rung on the shared avatar scale — never a pixel count. */
10
+ size?: AvatarSize;
9
11
  source?: ImageSource;
10
12
  name?: string;
11
13
  style?: StyleProp<ViewStyle | ImageStyle>;
@@ -30,8 +32,9 @@ interface AvatarProps {
30
32
  */
31
33
  export function Avatar(props: AvatarProps) {
32
34
  const locale = useLoticsLocale();
33
- const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
35
+ const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
34
36
  const decorative = !announce;
37
+ const px = AVATAR_PX[size];
35
38
 
36
39
  if (!source || !source.uri) {
37
40
  return (
@@ -43,7 +46,7 @@ export function Avatar(props: AvatarProps) {
43
46
  aria-hidden={decorative || undefined}
44
47
  style={[
45
48
  styles.base,
46
- { backgroundColor: colors.blue["600"], width: size, height: size },
49
+ { backgroundColor: colors.blue["600"], width: px, height: px },
47
50
  style,
48
51
  ]}
49
52
  >
@@ -51,14 +54,14 @@ export function Avatar(props: AvatarProps) {
51
54
  on the container so the SR does not read "HM" in addition. */}
52
55
  <Text
53
56
  userSelect="none"
54
- size="xs"
57
+ size={AVATAR_TEXT[size]}
55
58
  weight="medium"
56
59
  color="inverted"
57
60
  accessibilityElementsHidden
58
61
  importantForAccessibility="no-hide-descendants"
59
62
  aria-hidden
60
63
  >
61
- {getInitials(name, size)}
64
+ {avatarInitials(name, size)}
62
65
  </Text>
63
66
  </View>
64
67
  );
@@ -69,30 +72,13 @@ export function Avatar(props: AvatarProps) {
69
72
  accessibilityLabel={decorative ? undefined : name}
70
73
  accessibilityElementsHidden={decorative}
71
74
  importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
72
- style={[styles.base, { width: size, height: size }, style as ImageStyle]}
75
+ style={[styles.base, { width: px, height: px }, style as ImageStyle]}
73
76
  source={{ uri: source.uri }}
74
77
  resizeMode={contentFit === "contain" ? "contain" : "cover"}
75
78
  />
76
79
  );
77
80
  }
78
81
 
79
- function getInitials(name: string, size?: number): string {
80
- let initials = 2;
81
-
82
- if (size && size <= 32) {
83
- initials = 1;
84
-
85
- if (name.length <= 2) {
86
- return name;
87
- }
88
- }
89
-
90
- return name
91
- .split(" ")
92
- .map((c) => c.charAt(0).toUpperCase())
93
- .slice(0, initials)
94
- .join("");
95
- }
96
82
 
97
83
  const styles = StyleSheet.create({
98
84
  base: {
@@ -0,0 +1,77 @@
1
+ import type { TextSize } from "./text";
2
+
3
+ /**
4
+ * ONE scale for every avatar in the product — `Avatar`, `MemberChip`,
5
+ * `GroupAvatar`.
6
+ *
7
+ * It replaces a free `size?: number`, and the reason is not tidiness. Across the
8
+ * product and the apps that number had been given ELEVEN different values for the
9
+ * same concept (18, 20, 22, 24, 28, 32, 36, 40, 48, 64, 80), which is what made a
10
+ * person look different depending on which screen you met them on. A closed scale
11
+ * cannot drift that way, and it lets the initials rule below key off a rung the
12
+ * component actually knows rather than an arbitrary pixel count.
13
+ */
14
+ export type AvatarSize = "sm" | "md" | "lg" | "xl";
15
+
16
+ export const AVATAR_PX: Record<AvatarSize, number> = {
17
+ /** Dense rows and inline chips — one initial, because two do not fit. */
18
+ sm: 24,
19
+ /** The default: registers, lists, anywhere a person is a row. */
20
+ md: 36,
21
+ /** Prominent rows, the account menu. */
22
+ lg: 48,
23
+ /** A profile's own header. */
24
+ xl: 72,
25
+ };
26
+
27
+ /**
28
+ * The initials' type size, so a large avatar does not wear tiny letters — the
29
+ * old fixed `xs` made an 80px circle look empty.
30
+ *
31
+ * Measured in the gallery on web (a `TextSize` resolves to different pixels on
32
+ * native, and `text.css` moves again at 768px, so these are the desktop numbers
33
+ * and only a render can give them):
34
+ *
35
+ * sm 12 on 24 = 50% · md 14 on 36 = 39%
36
+ * lg 20 on 48 = 42% · xl 32 on 72 = 44%
37
+ *
38
+ * The two-letter rungs sit within 6 points of each other, so an avatar reads the
39
+ * same at every size rather than growing emptier as it grows — `lg` was on `md`
40
+ * type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by design:
41
+ * one letter in a small circle needs the weight.
42
+ */
43
+ export const AVATAR_TEXT: Record<AvatarSize, TextSize> = {
44
+ sm: "xs", // 12 on 24 — one letter, so it carries a larger share of the circle
45
+ md: "sm", // 14 on 36
46
+ lg: "lg", // 18 on 48
47
+ xl: "xxl", // 28 on 72
48
+ };
49
+
50
+ const words = (name: string): string[] => name.trim().split(/\s+/).filter((w) => w !== "");
51
+
52
+ /**
53
+ * The initials shown when a member has no photo.
54
+ *
55
+ * TWO LETTERS, AND THEY ARE THE FIRST AND **LAST** WORDS — not the first two.
56
+ * A Vietnamese name is họ + đệm + tên, so the first two words are the family name
57
+ * and a middle name shared by half the office: "Vũ Thị Lan" and "Vũ Thị Ngọc" both
58
+ * reduce to "VT". Taking the first and last gives VL and VN. Measured on a real
59
+ * 29-person workspace, first-two-words leaves 11 people indistinguishable and
60
+ * first-and-last leaves 5. It is also what English display initials have always
61
+ * been — "Mary Jane Watson" is MW — so this is a correction, not a localization.
62
+ *
63
+ * `sm` gets ONE letter: two do not fit legibly at 24px, which is the whole reason
64
+ * the rung exists. Every larger rung gets two — where the old rule keyed off a
65
+ * `<= 32px` threshold that every call site in both apps happened to sit under, so
66
+ * in practice the product only ever showed a single letter.
67
+ */
68
+ export function avatarInitials(name: string, size: AvatarSize): string {
69
+ const parts = words(name);
70
+ if (parts.length === 0) return "";
71
+ const first = parts[0];
72
+ if (size === "sm") return first.charAt(0).toUpperCase();
73
+ // A single-word name gives up its second letter rather than a lone initial
74
+ // floating in a 36px circle — "Nga" reads better as "NG" than as "N".
75
+ if (parts.length === 1) return first.slice(0, 2).toUpperCase();
76
+ return (first.charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
77
+ }
@@ -1,32 +1,41 @@
1
1
  import { StyleSheet, View } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
3
  import { colors } from "@lotics/ui/colors";
4
+ import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "@lotics/ui/avatar_size";
4
5
 
5
6
  export interface GroupAvatarProps {
6
7
  name: string;
7
- size?: number;
8
+ /** A rung on the shared avatar scale. Default `md`. */
9
+ size?: AvatarSize;
8
10
  }
9
11
 
10
12
  /**
11
- * Renders the first letter of a name in a styled rounded square. Use
12
- * for entities without an image (groups, organizations, tags).
13
+ * Renders a name's initials in a styled rounded square. Use for entities
14
+ * without an image (groups, organizations, tags).
15
+ *
16
+ * Shares `Avatar`'s scale AND its initials rule, so a member and a group
17
+ * standing next to each other in one list — a permission list, an approver
18
+ * picker, a search result — read as the same kind of thing. A group left on a
19
+ * single letter beside a person showing two was the tell that the two had
20
+ * drifted apart.
13
21
  */
14
22
  export function GroupAvatar(props: GroupAvatarProps) {
15
- const { name, size = 40 } = props;
23
+ const { name, size = "md" } = props;
24
+ const px = AVATAR_PX[size];
16
25
 
17
26
  return (
18
27
  <View
19
28
  style={[
20
29
  styles.container,
21
30
  {
22
- width: size,
23
- height: size,
24
- borderRadius: size * 0.2,
31
+ width: px,
32
+ height: px,
33
+ borderRadius: px * 0.2,
25
34
  },
26
35
  ]}
27
36
  >
28
- <Text size="sm" weight="medium">
29
- {name.charAt(0).toUpperCase()}
37
+ <Text size={AVATAR_TEXT[size]} weight="medium">
38
+ {avatarInitials(name, size)}
30
39
  </Text>
31
40
  </View>
32
41
  );
@@ -4,6 +4,7 @@ import type { InlineEditVariant } from "./inline_edit";
4
4
  import { InlineSelect } from "./inline_select";
5
5
  import { MemberChip } from "./member_chip";
6
6
  import { Avatar } from "./avatar";
7
+ import { AVATAR_PX, type AvatarSize } from "./avatar_size";
7
8
  import { Icon } from "./icon";
8
9
  import { colors } from "./colors";
9
10
  import type { MemberSelectMember } from "./member_select";
@@ -42,7 +43,11 @@ interface InlineMemberSelectProps {
42
43
  avatarOnly?: boolean;
43
44
  }
44
45
 
45
- const AVATAR_ONLY_SIZE = 24;
46
+ // The avatar-only variant sits in a dense inline cell — `sm`. The placeholder
47
+ // box below takes its pixels from the SAME rung, so the empty slot can never
48
+ // stop matching the avatar that fills it.
49
+ const AVATAR_ONLY: AvatarSize = "sm";
50
+ const AVATAR_ONLY_SIZE = AVATAR_PX[AVATAR_ONLY];
46
51
 
47
52
  /**
48
53
  * Edit a `select_member` field in place: the assigned member shows as a
@@ -79,7 +84,7 @@ export function InlineMemberSelect(props: InlineMemberSelectProps) {
79
84
  (option: PickerOption<string>) => {
80
85
  const member = byId.get(option.value);
81
86
  if (!member) return null;
82
- return <Avatar size={AVATAR_ONLY_SIZE} name={member.name || member.email || member.id} source={member.image ? { uri: member.image } : undefined} announce />;
87
+ return <Avatar size={AVATAR_ONLY} name={member.name || member.email || member.id} source={member.image ? { uri: member.image } : undefined} announce />;
83
88
  },
84
89
  [byId],
85
90
  );
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
3
  import { Avatar } from "./avatar";
4
4
  import { Text } from "./text";
5
+ import type { AvatarSize } from "./avatar_size";
5
6
 
6
7
  interface MemberChipProps {
7
8
  /** Display name. Empty/blank falls back to a neutral label — pass
@@ -11,8 +12,8 @@ interface MemberChipProps {
11
12
  image?: string | null;
12
13
  /** Optional secondary line under the name — e.g. email, role, department. */
13
14
  secondary?: string | null;
14
- /** Avatar diameter in px. Default 28. */
15
- size?: number;
15
+ /** A rung on the shared avatar scale. Default `md`. */
16
+ size?: AvatarSize;
16
17
  style?: StyleProp<ViewStyle>;
17
18
  }
18
19
 
@@ -26,7 +27,7 @@ interface MemberChipProps {
26
27
  * `select_member` cell joined against that roster); this component fetches
27
28
  * nothing and carries no domain types.
28
29
  */
29
- export function MemberChip({ name, image, secondary, size = 28, style }: MemberChipProps) {
30
+ export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
30
31
  const displayName = name?.trim() || "Unknown";
31
32
  return (
32
33
  <View style={[styles.row, style]}>
package/src/table.tsx CHANGED
@@ -35,6 +35,30 @@ export interface TableColumn extends TableFitColumn {
35
35
  sortable?: boolean;
36
36
  }
37
37
 
38
+ /**
39
+ * How cells sit on the row's CROSS axis.
40
+ *
41
+ * `center` (the default) — every cell centred in the row. Right for a register
42
+ * whose cells are one line each, which is most of them, and the reason this is
43
+ * not being changed underneath anyone.
44
+ *
45
+ * `top` — cells share a top edge, so the FIRST LINE of every cell lands on one
46
+ * baseline. Reach for it the moment cells carry different numbers of lines: an
47
+ * identity cell with a supporting line (2), a status cell with a bar over a
48
+ * label (3), a member chip (1) and a wrapped cell (2) centred together put four
49
+ * first lines at four different heights, and the eye steps down and back up
50
+ * across a single row. Measured on a live register: a 12px spread.
51
+ *
52
+ * It governs the CELLS only. The ordinal, the leading checkbox and the trailing
53
+ * ⋯ stay centred in the row — they are chrome, they are one line by
54
+ * construction, and pinning them to the top of a tall row would strand them.
55
+ *
56
+ * It applies to EVERY column row — pressable and read-only alike — because a
57
+ * table that mixed the two would otherwise align its own rows differently from
58
+ * each other. A STACKED row (tier 3) is unaffected: it has no columns to line up.
59
+ */
60
+ export type TableAlign = "center" | "top";
61
+
38
62
  interface TableCtx {
39
63
  columns: TableColumn[];
40
64
  leading: number;
@@ -43,6 +67,7 @@ interface TableCtx {
43
67
  trailing: number;
44
68
  visibleKeys: ReadonlySet<string>;
45
69
  stacked: boolean;
70
+ align: TableAlign;
46
71
  }
47
72
  const TableContext = createContext<TableCtx | null>(null);
48
73
 
@@ -123,6 +148,9 @@ export interface TableProps {
123
148
  count?: number;
124
149
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
125
150
  trailing?: number;
151
+ /** How cells sit on the row's cross axis — see {@link TableAlign}. Default
152
+ * `center`; pass `top` when cells carry different numbers of lines. */
153
+ align?: TableAlign;
126
154
  /** The `TableRow`s. */
127
155
  children: ReactNode;
128
156
  }
@@ -142,7 +170,7 @@ export interface TableProps {
142
170
  * keeps its current order). See `computeTableFit`.
143
171
  */
144
172
  export function Table(props: TableProps) {
145
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
173
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, align = "center", children } = props;
146
174
  const ordinal = count != null ? ORDINAL_W : 0;
147
175
  const rows = Children.toArray(children).filter(isValidElement);
148
176
 
@@ -160,7 +188,7 @@ export function Table(props: TableProps) {
160
188
  const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
161
189
 
162
190
  return (
163
- <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
191
+ <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked, align }}>
164
192
  {/* ONE layout node: without this wrapper the header band + body land as two
165
193
  direct flex children of the app's container, and a parent column `gap`
166
194
  (the standard section spacing) opens a hole between the header and rows. */}
@@ -312,6 +340,12 @@ export function TableRow(props: TableRowProps) {
312
340
  // no cell, and column 0 never drops in register mode either.)
313
341
  .map(({ cell, column }, i) => cloneElement(cell, { _column: column, _stacked: ctx.stacked, _primary: i === 0 }));
314
342
 
343
+ // ONE style for the column row, shared by the pressable and read-only paths
344
+ // below. They rendered near-identical Views and `align` was added to only one,
345
+ // so a table mixing the two aligned its own rows differently — computing it
346
+ // once is what makes that divergence impossible rather than merely fixed.
347
+ const cellsStyle = [styles.cells, { minHeight }, ctx.align === "top" ? styles.cellsTop : null];
348
+
315
349
  const body = ctx.stacked ? (
316
350
  <View style={styles.stackedBody}>
317
351
  <View style={styles.stackedTopLine}>
@@ -346,7 +380,7 @@ export function TableRow(props: TableRowProps) {
346
380
  return (
347
381
  <View style={styles.staticRow}>
348
382
  <LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
349
- <View style={[styles.cells, { minHeight }]}>{cells}</View>
383
+ <View style={cellsStyle}>{cells}</View>
350
384
  {ctx.trailing > 0 ? (
351
385
  <View style={[styles.trailingSlot, { width: ctx.trailing }]}>
352
386
  {action}
@@ -368,7 +402,7 @@ export function TableRow(props: TableRowProps) {
368
402
  body
369
403
  ) : (
370
404
  <>
371
- <View style={[styles.cells, { minHeight }]}>{cells}</View>
405
+ <View style={cellsStyle}>{cells}</View>
372
406
  {ctx.trailing > 0 ? (
373
407
  <View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
374
408
  {action}
@@ -495,6 +529,11 @@ const styles = StyleSheet.create({
495
529
  gap: COLUMN_GAP,
496
530
  zIndex: 1,
497
531
  },
532
+ // `align="top"` — see `TableAlign`. Overrides the centring above so unequal
533
+ // cells share a first-line baseline; the row's own chrome stays centred.
534
+ cellsTop: {
535
+ alignItems: "flex-start",
536
+ },
498
537
  // Leading/trailing gutters carry their own controls — lifted above the door
499
538
  // like the cells.
500
539
  slot: {