@lotics/ui 44.1.0 → 44.3.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/AGENTS.md CHANGED
@@ -27,8 +27,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
27
27
  - **Reuse first; the catalog can lag `src/`.** Before hand-rolling ANY capability, `ls src/` and
28
28
  grep for a match — a component in `src/` missing from the catalog is a doc bug to fix, not a
29
29
  license to hand-roll.
30
- - **One canonical component per data role** (member → `MemberChip`, select `OptionBadge`,
31
- files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
30
+ - **One canonical component per data role** (member → `MemberChip`, who-is-this
31
+ `MemberPeek`/`MemberProfileCard`, select → `OptionBadge`, files → `FilePreview` family, …) —
32
+ the catalog's Reach-by-role outranks neighboring code.
32
33
  - **Responsive layout measures the CONTAINER, not the screen.** `useContainerSize()` reports the
33
34
  nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's, and
34
35
  `Dialog`/`Drawer`/popover bodies already are boundaries so their contents get the panel
@@ -108,6 +108,7 @@ Key props (full API: `../src/composer.tsx`):
108
108
  | `footerRight` | Footer-right content before Send (a model picker); forces expanded |
109
109
  | `maxLines` | Expanded growth cap before the input scrolls (default 10) |
110
110
  | `sendLabel` / `stopLabel` | Accessible/tooltip labels (default English "Send"/"Stop" — pass translations) |
111
+ | `highlightRanges` | Character ranges of `value` to tint. What a range MEANS is yours — the composer paints a ground behind those characters and nothing more. It renders a mirror BEHIND the real field rather than styling the field itself, so the browser stays the editor and IME, undo, paste and selection are untouched; omit the prop and no mirror is rendered at all |
111
112
  | `textInputProps` | Extra `TextInput` props; a consumer `onKeyPress` runs before Enter-to-send and can `preventDefault()` to suppress it |
112
113
 
113
114
  For a run that needs a FILE + a prompt together (attach a photo, review/remove it, add a note,
package/docs/catalog.md CHANGED
@@ -78,6 +78,31 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
78
78
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
79
79
  renders these per option.)
80
80
 
81
+ **To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
82
+ `MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
83
+ joined. The chip wears `marker` there — a pressable that paints nothing at rest is text, and its
84
+ hover wash does not exist on touch. Feed it
85
+ a member (an app's `ResolvedMember` already carries `groups`); `role` arrives ALREADY
86
+ TRANSLATED, never a raw enum — the kit holds no domain vocabulary. Use `MemberProfileCard`
87
+ alone wherever the card is not behind a press (a profile header, a drawer).
88
+
89
+ **Where a peek must NOT go**, both cases being one rule — *two destinations behind one
90
+ object*: a PICKER OPTION (`MemberSelect` / `InlineMemberSelect` render a chip per option, and
91
+ a card over the open menu fires exactly when the reader is choosing), and a ROW THAT ALREADY
92
+ PRESSES (the row opens the record; a chip inside it would open something else four pixels
93
+ away). Those keep the plain `MemberChip`. A peek belongs where nothing else on the line
94
+ presses — a person named in prose, a static header, a detail row, a log whose rows do not
95
+ navigate. **Judge the container, not its look:** a `DataGrid` group band names a person and
96
+ reads inert, but it is a button that collapses the group, so a peek there nests a button in a
97
+ button. A fully interactive screen often has NO valid spot — `examples/tpl_task_board.tsx`
98
+ names a person three times (collapsing band, inline editor, picker option) and correctly
99
+ peeks on none of them.
100
+
101
+ **`groups` distinguishes absent from empty, and the card renders the difference**: omitted
102
+ drops the row (this caller was never told — a public-app response, an older server), `[]`
103
+ keeps it and says "None". Passing `[]` for "we don't know" states as fact that a colleague is
104
+ on no team.
105
+
81
106
  **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
82
107
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
83
108
  Pick by density:
@@ -545,7 +570,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
545
570
  - **`member_chip`** — `MemberChip`: avatar + name; the universal person render. `size` scales
546
571
  BOTH — `sm` gives a 24px avatar and a 12px name, every larger rung keeps the name at body
547
572
  size. Reach for `sm` when the chip sits inside a sentence or a dense row, where a body-size
548
- name would collide with the 12px text around it.
573
+ name would collide with the 12px text around it. Also exports `memberDisplayName(name,
574
+ unknown)` — the shared "a blank name is called X" rule, so a chip and the card it opens
575
+ cannot name the same nameless member two different things.
576
+ - **`member_profile_card`** — `MemberProfileCard`: WHO IS THIS — `xl` avatar, name, sign-in
577
+ identity, role, join date and groups, plus one optional `action`. Every row is conditional, so a
578
+ card told nothing is a header and nothing else. `role` comes in pre-translated (vocabulary only
579
+ the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year
580
+ (a date has a canonical form the kit owns, so two hosts formatting it themselves would drift);
581
+ `groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
582
+ a `Badge` — a lifecycle state, not another muted line.
583
+ - **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
584
+ announced name taken from the locale. Off picker options and off rows that already press —
585
+ see the person-display section for why.
549
586
 
550
587
  ### Layout & surfaces
551
588
 
@@ -1562,6 +1599,11 @@ component rather than showing it at zero.
1562
1599
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1563
1600
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1564
1601
  KPI can be driven off the same numbers the grid shows.
1602
+ - **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
1603
+ highlighted runs for `Composer`'s `highlightRanges`. React-free, and the reason it is
1604
+ separate: it feeds a mirror sitting behind a real text field, so a boundary off by one
1605
+ paints the tint adrift while everything still looks like a working composer. Overlapping,
1606
+ unsorted and out-of-bounds ranges all normalize; no input drops a character.
1565
1607
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1566
1608
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
1567
1609
  (`RemainderMeterLabels` localized via the provider).
@@ -83,6 +83,17 @@ restyle a heading level per-page.
83
83
  - **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
84
84
  `CardHeaderTitle` (`sm` semibold; pass `info` whenever the title alone doesn't define the
85
85
  numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
86
+ - **Panel identity band** — a self-contained OVERLAY (a peek, a profile card, a small drawer)
87
+ whose subject is an ENTITY rather than a dataset. The name takes `###` — `lg` semibold, the
88
+ lowest heading rank — and neither of the two constructs it sits between: `CardHeaderTitle`
89
+ (`sm`) labels a CONTAINER of data, whereas here the name IS the content; `RecordSummary`'s `#`
90
+ is a page/drawer rung, and a page rung inside a popover flattens the very altitude distinction
91
+ the ramp exists to keep. **The failure it prevents is measurable:** a body-size name leaves the
92
+ panel's subject separated from its own supporting line by WEIGHT alone, which is a separate
93
+ font file rather than a step, and the whole surface then measures ~1.17x — flatter than a
94
+ register with no page band. Its supporting line takes the rung that gives it SIBLINGS (the
95
+ other muted lines in the same block), which is what "one rung below" is serving; applied
96
+ literally against that it produces a singleton treatment instead.
86
97
  - **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
87
98
  (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
88
99
  at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
@@ -1045,6 +1056,29 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1045
1056
  **One verb set across every reference of a group** (a party wall's five links all read "Gỡ", not
1046
1057
  a mix of "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker
1047
1058
  re-prompts, so the destructive verb covers both required anchors and optional adds.
1059
+ - **A PERSON token peeks the same way a record reference does — but only where nothing else
1060
+ on the line presses.** `MemberPeek` makes a `MemberChip` a door onto `MemberProfileCard`
1061
+ (a `xl` avatar, the sign-in identity, role, teams), and it is the same drill-down grammar:
1062
+ identity on the surface, the facts one layer in. The constraint is where it may live, and
1063
+ it is the interior-verb lesson above generalised past the reference field: **a token inside
1064
+ something already pressable puts two destinations behind one object.** Two surfaces fail it
1065
+ and both keep the plain chip — a PICKER OPTION (a card over the open menu, fired at the one
1066
+ moment the reader is choosing rather than browsing) and a ROW THAT NAVIGATES (press the row,
1067
+ open the record; press four pixels left, open a card). What is left is where a peek is
1068
+ actually for: a person named in prose, a static header, a detail row, a log whose rows do
1069
+ not navigate. The test generalises to any token — ask what ELSE the press would have done,
1070
+ and if the answer is "something", the token stays inert and the surface it sits on carries
1071
+ the drill-down.
1072
+ **Judge the CONTAINER, not how inert it looks.** A grouped register's band header names a
1073
+ person, carries no visible control, and is a button — it collapses the group — so a token
1074
+ inside it nests a button in a button and splits one gesture in two. The corollary is worth
1075
+ stating because it reads as a failure and is not: a screen built entirely of editors,
1076
+ pickers and navigating rows has NOWHERE a peek belongs, and the right move there is no peek
1077
+ at all rather than the least-bad host.
1078
+ **A profile's rows are conditional, and absent is not empty**: a field nobody supplied has
1079
+ no row, while a field supplied as EMPTY keeps its row and says so. Collapsing the two turns
1080
+ "we were not told" into a confident claim about a colleague — the same distinction a
1081
+ resolved member cell already carries on the wire, and it must survive the render.
1048
1082
 
1049
1083
  ## The pointer cursor
1050
1084
 
@@ -1377,6 +1411,22 @@ default nobody chose — the giveaway is that the call site mentions no colour a
1377
1411
  arrived from a component's fallback rather than from a decision. Either give it siblings or take it
1378
1412
  back to a neutral.
1379
1413
 
1414
+ **SCALE may vary by altitude; INK may not.** Two surfaces rendering the same thing at different
1415
+ sizes is the ramp working — a dense chip and a panel legitimately put a person's name on different
1416
+ rungs. Two surfaces rendering it in different INKS is a defect, because an ink is a MEANING
1417
+ (supporting, departed, danger) while a rung is only a size. The signature is one fact measuring two
1418
+ colours on one screen: collect every element's `color` grouped by what it MEANS, and any meaning
1419
+ holding more than one value — or any value serving more than one meaning — is the finding, whatever
1420
+ the sizes are.
1421
+
1422
+ **Supporting text is `muted`, and a raw neutral in its place is the usual cause of the above.**
1423
+ `color="muted"` is the token that means "this supports something else"; a hand-written
1424
+ `color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
1425
+ already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
1426
+ and a supporting line ends up wearing the ink that means "this person has left", so on a departed
1427
+ row the mute and the metadata come out identical and the state stops signalling anything. Grep a
1428
+ component for a raw neutral on `Text` before trusting that it reads muted.
1429
+
1380
1430
  **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
1381
1431
  `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
1382
1432
  (emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
package/docs/reviewing.md CHANGED
@@ -96,8 +96,26 @@ rule behind it lives in the area doc named beside it; this file never restates o
96
96
  before meaning, so an unexplained difference reads as an accident however principled the reason.
97
97
  - **Singletons.** A rung/weight/ink combination appearing ONCE either wants siblings or wants to
98
98
  join an existing class. Pick the sibling set by STRUCTURAL ROLE, not visual resemblance.
99
+ - **Count DISTINCT rungs on the surface, then name the role each one carries.** More rungs than
100
+ roles means a role has been split across two sizes, and that is the defect a reader calls
101
+ "inconsistent" without being able to point at which element is wrong — because none of them is:
102
+ each was chosen defensibly, one at a time. This is the probe to re-run after EVERY fix, and the
103
+ reason is procedural rather than visual: a treatment fix aimed at the element someone pointed at
104
+ leaves its neighbours unaligned, so the next look finds a new mismatch and the surface never
105
+ converges. Fixing the SET is a different act from fixing an element, and only this count tells
106
+ the two apart. A small panel wants two or three rungs; if the tally exceeds the number of things
107
+ the surface has to SAY, the excess is the finding.
99
108
  - **Pairs.** For every primary+supporting pair, divide. A spread in the RATIOS is the finding, not
100
- a spread in the sizes.
109
+ a spread in the sizes. **A ratio of 1.00 is the loudest version** — a subject and its supporting
110
+ line on one rung are distinguishable only by ink, and if they also share a weight, only by ink.
111
+ - **Stacked pairs: measure the STACK against the type inside it.** Collect each line's `font-size`
112
+ and `line-height` and the pair's total height. Prose leading is ~1.7x the rung, so two stacked
113
+ lines carry roughly half a line of empty box between the glyphs and read as two objects rather
114
+ than one — the signature is a stack noticeably taller than the sum of its font sizes (14+14 in a
115
+ 48px box), with the lines' own boxes TOUCHING, so no margin or gap appears anywhere to explain
116
+ it and every spacing probe passes. Beside a mark, the same pair also outgrows the mark it labels.
117
+ The fix is `leading="tight"` on BOTH lines, never a negative margin, and never on a lone line —
118
+ one line is not a pair, and tightening it changes the geometry of every single-line instance.
101
119
  - **A label sharing a row with a flexible value: measure the LABEL across rows whose values differ
102
120
  in LENGTH.** Whichever of the two cannot shrink forces the other to, so a value floored at its
103
121
  longest word leaves the label absorbing every pixel and collapsing to a one-character column
@@ -322,6 +322,15 @@ 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
+ // A PLAIN chip, and deliberately so. It is tempting to make the person
326
+ // here open their profile (`MemberPeek`), and it would be wrong: this
327
+ // band is itself a button — `DataGrid` wraps a group header in a
328
+ // pressable that collapses the group — so a card on the chip puts two
329
+ // destinations behind one object and nests a button inside a button.
330
+ // The same holds everywhere this screen names somebody: the assignee
331
+ // CELL is an editor, and the assignee FILTER is a picker option. A
332
+ // working board has no inert person on it, which is why the peek lives
333
+ // on static surfaces — a header that does nothing, a detail row, a log.
325
334
  ...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size="sm" />, items: pool.filter((t) => t.ownerId === m.id) })),
326
335
  { key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((t) => !t.ownerId) },
327
336
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.1.0",
3
+ "version": "44.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -16,6 +16,8 @@
16
16
  "./option_picker": "./src/option_picker.tsx",
17
17
  "./option_badge": "./src/option_badge.tsx",
18
18
  "./member_chip": "./src/member_chip.tsx",
19
+ "./member_profile_card": "./src/member_profile_card.tsx",
20
+ "./member_peek": "./src/member_peek.tsx",
19
21
  "./member_select": "./src/member_select.tsx",
20
22
  "./inline_member_select": "./src/inline_member_select.tsx",
21
23
  "./mime": "./src/mime.ts",
package/src/composer.tsx CHANGED
@@ -1,9 +1,19 @@
1
- import React, { type ReactNode, type Ref, useCallback, useEffect, useState } from "react";
2
- import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
1
+ import React, { type ReactNode, type Ref, useCallback, useEffect, useMemo, useState } from "react";
2
+ import {
3
+ View,
4
+ Text as RNText,
5
+ TextInput as RNTextInput,
6
+ ScrollView,
7
+ StyleSheet,
8
+ type NativeSyntheticEvent,
9
+ type TextInputProps,
10
+ type TextInputScrollEventData,
11
+ } from "react-native";
3
12
  import { IconButton } from "./icon_button";
4
13
  import { colors } from "./colors";
5
14
  import { Text } from "./text";
6
15
  import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
16
+ import { splitHighlightSegments, type HighlightRange } from "./highlight_segments";
7
17
  import { useAutoGrowHeight } from "./use_auto_grow_height";
8
18
  import { useLoticsLocale } from "./locale";
9
19
 
@@ -43,6 +53,10 @@ export interface ComposerProps {
43
53
  /** Control text externally; omit to let the composer manage its own state. */
44
54
  value?: string;
45
55
  onChangeText?: (text: string) => void;
56
+ /** Ranges of `value` to tint. What they MEAN is the caller's business — this
57
+ * package never learns what a skill or a document is. Omit it and nothing is
58
+ * rendered, so a consumer that never tints pays nothing. */
59
+ highlightRanges?: readonly HighlightRange[];
46
60
  /** Extra TextInput props (ref, onKeyPress, …). `onKeyPress` runs before the
47
61
  * built-in Enter-to-send and can `preventDefault()` to suppress it. */
48
62
  textInputProps?: Partial<TextInputProps> & { ref?: Ref<RNTextInput> };
@@ -79,6 +93,7 @@ export function Composer(props: ComposerProps) {
79
93
  autoFocus,
80
94
  testID,
81
95
  textInputProps,
96
+ highlightRanges,
82
97
  pills,
83
98
  files,
84
99
  children,
@@ -186,12 +201,85 @@ export function Composer(props: ComposerProps) {
186
201
  // padding; `measureScrollHeight` subtracts that padding, so wrap-detection is
187
202
  // unaffected. Growth past one line is the EXPANDED layout's job (up to maxLines).
188
203
  const compactPadV = Math.max(2, Math.round((COMPACT_INPUT_HEIGHT - lineHeight) / 2));
204
+
205
+ // Null — not an empty array — when there is nothing to tint, so the mirror is
206
+ // not rendered at all for the ordinary message or for a consumer that never
207
+ // passes ranges.
208
+ const highlightSegments = useMemo(
209
+ () =>
210
+ highlightRanges && highlightRanges.length > 0
211
+ ? splitHighlightSegments(text, highlightRanges)
212
+ : null,
213
+ [text, highlightRanges],
214
+ );
215
+ const [mirrorScrollY, setMirrorScrollY] = useState(0);
216
+ const handleInputScroll = useCallback(
217
+ (e: NativeSyntheticEvent<TextInputScrollEventData>) => {
218
+ // The two platforms report the offset in different places and neither has
219
+ // the other's: RN measures the content and sends `contentOffset`, while
220
+ // rn-web forwards the <textarea>'s DOM scroll event verbatim, where the
221
+ // only offset is the element's own `scrollTop`. Reading just the native
222
+ // shape type-checks — RN's types promise `contentOffset` — and then throws
223
+ // on the web, on every scroll tick, while the tint silently stops tracking
224
+ // the words in exactly the long message this handler exists to follow.
225
+ const native: Partial<TextInputScrollEventData> & { target?: { scrollTop?: number } } =
226
+ e.nativeEvent;
227
+ setMirrorScrollY(native.contentOffset?.y ?? native.target?.scrollTop ?? 0);
228
+ },
229
+ [],
230
+ );
189
231
  const field = (
190
232
  <View
191
233
  key="field"
192
234
  onLayout={measure}
193
235
  style={expanded ? { height: containerHeight } : [styles.field, { height: COMPACT_INPUT_HEIGHT }]}
194
236
  >
237
+ {highlightSegments ? (
238
+ // A MIRROR, not a replacement. The real <textarea> stays and keeps every
239
+ // behaviour the browser gives it — IME composition (this workspace types
240
+ // Vietnamese), undo, paste, spellcheck, selection, a11y — none of which
241
+ // survives a contentEditable rewrite. It renders TRANSPARENT text purely
242
+ // to lay out identically, and paints the tint behind the words the field
243
+ // is drawing on top; the field's own text and caret are untouched, so
244
+ // there is nothing here that can swallow a keystroke.
245
+ //
246
+ // Alignment is by construction: same text style object, same padding
247
+ // expressions, same width. It translates with the field's own scroll so
248
+ // the tint cannot drift once the message outgrows `maxLines`.
249
+ <View
250
+ pointerEvents="none"
251
+ style={[
252
+ StyleSheet.absoluteFill,
253
+ { overflow: "hidden" },
254
+ expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
255
+ ]}
256
+ >
257
+ <RNText
258
+ testID="composer-highlight-mirror"
259
+ // The RAW react-native Text, never the kit's: `Text` bakes tracking
260
+ // per type-ramp rung, which quietly overrode the explicit
261
+ // `letterSpacing` the field uses and drifted the tint a fraction of
262
+ // a pixel per character — invisible on a short line, off the words
263
+ // by the end of a long one. The mirror takes the field's own style
264
+ // array and nothing else.
265
+ style={[
266
+ styles.textInput,
267
+ getInputTextStyle(),
268
+ { color: "transparent" },
269
+ { transform: [{ translateY: -mirrorScrollY }] },
270
+ ]}
271
+ >
272
+ {highlightSegments.map((segment, i) => (
273
+ <RNText
274
+ key={i}
275
+ style={segment.highlighted ? styles.highlightSegment : undefined}
276
+ >
277
+ {segment.text}
278
+ </RNText>
279
+ ))}
280
+ </RNText>
281
+ </View>
282
+ ) : null}
195
283
  <RNTextInput
196
284
  ref={mergedInputRef}
197
285
  autoFocus={autoFocus}
@@ -205,11 +293,19 @@ export function Composer(props: ComposerProps) {
205
293
  multiline
206
294
  scrollEnabled={expanded && scrollEnabled}
207
295
  onContentSizeChange={onContentSizeChange}
296
+ onScroll={highlightSegments ? handleInputScroll : undefined}
208
297
  {...spreadInputProps}
209
298
  onKeyPress={handleKeyPress}
210
299
  style={[
211
300
  styles.textInput,
212
301
  getInputTextStyle(),
302
+ // ABOVE the mirror. The mirror is absolutely positioned, and a
303
+ // positioned box paints over static in-flow content whatever the
304
+ // source order — so without this its tint is an opaque ground drawn
305
+ // ON TOP of the field, and the words inside a mention disappear
306
+ // entirely. Geometry and font metrics can all agree while this is
307
+ // wrong; only rendering the thing shows it.
308
+ { position: "relative" as const, zIndex: 1 },
213
309
  { outlineStyle: "none" },
214
310
  (!expanded || !scrollEnabled) && { overflow: "hidden" as const },
215
311
  expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
@@ -348,4 +444,10 @@ const styles = StyleSheet.create({
348
444
  fontFamily: fontFamilyRegular,
349
445
  letterSpacing: INPUT_LETTER_SPACING,
350
446
  },
447
+ // A GROUND, not coloured text: the words on screen are the field's, drawn on
448
+ // top, so the tint has to sit behind them rather than replace them.
449
+ highlightSegment: {
450
+ backgroundColor: colors.blue[100],
451
+ borderRadius: 4,
452
+ },
351
453
  });
@@ -0,0 +1,61 @@
1
+ export interface HighlightRange {
2
+ /** Index of the first highlighted character. */
3
+ start: number;
4
+ /** Index one past the last highlighted character. */
5
+ end: number;
6
+ }
7
+
8
+ export interface HighlightSegment {
9
+ text: string;
10
+ highlighted: boolean;
11
+ }
12
+
13
+ /**
14
+ * Cut `value` into alternating plain and highlighted runs.
15
+ *
16
+ * Split out from the composer because it is the part that can be wrong in a way
17
+ * nobody sees: the mirror it feeds sits behind a real text field, so a segment
18
+ * boundary off by one paints the tint half a character adrift and everything
19
+ * still looks like a working composer. Ranges arrive from a caller that is
20
+ * matching text it does not control, so overlapping, unsorted, reversed and
21
+ * out-of-bounds inputs are all normal here — none of them may drop or duplicate
22
+ * a character, because the mirror has to lay out identically to the field it
23
+ * sits behind or the tint drifts from the words.
24
+ */
25
+ export function splitHighlightSegments(
26
+ value: string,
27
+ ranges: readonly HighlightRange[],
28
+ ): HighlightSegment[] {
29
+ if (value.length === 0) return [];
30
+
31
+ const clamped = ranges
32
+ .map((r) => ({
33
+ start: Math.max(0, Math.min(r.start, value.length)),
34
+ end: Math.max(0, Math.min(r.end, value.length)),
35
+ }))
36
+ .filter((r) => r.end > r.start)
37
+ .sort((a, b) => a.start - b.start);
38
+
39
+ // Overlaps merge rather than nest: two tints over one character would render
40
+ // twice as dark, and the caller's ranges are matches over shared text.
41
+ const merged: HighlightRange[] = [];
42
+ for (const range of clamped) {
43
+ const last = merged[merged.length - 1];
44
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
45
+ else merged.push({ ...range });
46
+ }
47
+
48
+ const segments: HighlightSegment[] = [];
49
+ let cursor = 0;
50
+ for (const range of merged) {
51
+ if (range.start > cursor) {
52
+ segments.push({ text: value.slice(cursor, range.start), highlighted: false });
53
+ }
54
+ segments.push({ text: value.slice(range.start, range.end), highlighted: true });
55
+ cursor = range.end;
56
+ }
57
+ if (cursor < value.length) {
58
+ segments.push({ text: value.slice(cursor), highlighted: false });
59
+ }
60
+ return segments;
61
+ }
package/src/locale.tsx CHANGED
@@ -9,6 +9,7 @@ import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
9
9
  import { type GalleryLabels } from "./file_preview_types";
10
10
  import { type FindingLabels } from "./finding";
11
11
  import { type DeadlineLabels } from "./deadline";
12
+ import { type MemberProfileLabels } from "./member_profile_card";
12
13
 
13
14
  /**
14
15
  * The kit's localizable strings, one slice per string-bearing component. A
@@ -125,8 +126,13 @@ export interface LoticsLocale {
125
126
  * string cannot mean both. Agnostic on purpose: the kit does not know
126
127
  * whether it is showing a recording, a clip or an attachment. */
127
128
  mediaPlayer: { loadFailed: string };
128
- /** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
129
+ /** `Avatar`: the fallback name (initials + a11y label) shown when no `name`.
130
+ * `MemberChip` resolves the same blank through it, so a person with no name
131
+ * is called one thing on both. */
129
132
  avatar: { unknown: string };
133
+ /** `MemberProfileCard` + `MemberPeek`: the profile card's row labels, its
134
+ * "no groups" and departed notes, and the trigger's announced name. */
135
+ memberProfile: Required<MemberProfileLabels>;
130
136
  /** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
131
137
  nav: { back: string };
132
138
  /** `BarChart` / `LineChart` / `PieChart`: the empty-state caption and the
@@ -338,6 +344,14 @@ export const en: LoticsLocale = {
338
344
  passwordProtected: "This file is password-protected and cannot be previewed",
339
345
  },
340
346
  avatar: { unknown: "Unknown" },
347
+ memberProfile: {
348
+ role: "Role",
349
+ groups: "Groups",
350
+ joined: "Joined",
351
+ noGroups: "None",
352
+ inactive: "No longer active",
353
+ profile: (name) => `Profile of ${name}`,
354
+ },
341
355
  nav: { back: "Back" },
342
356
  chart: { noData: "No data", total: "Total" },
343
357
  composer: { send: "Send", stop: "Stop" },
@@ -510,6 +524,17 @@ export const vi: LoticsLocale = {
510
524
  },
511
525
  mediaPlayer: { loadFailed: "Không tải được nội dung" },
512
526
  avatar: { unknown: "Không rõ" },
527
+ memberProfile: {
528
+ role: "Vai trò",
529
+ groups: "Nhóm",
530
+ joined: "Tham gia",
531
+ noGroups: "Không có",
532
+ inactive: "Đã nghỉ",
533
+ // "Thông tin của …", not "Hồ sơ của …": in a Vietnamese workspace a hồ sơ is
534
+ // a DOSSIER — an application file, a case — so announcing a colleague's card
535
+ // that way names the wrong object on the very surface that has no picture.
536
+ profile: (name) => `Thông tin của ${name}`,
537
+ },
513
538
  nav: { back: "Quay lại" },
514
539
  chart: { noData: "Không có dữ liệu", total: "Tổng" },
515
540
  composer: { send: "Gửi", stop: "Dừng" },
@@ -3,6 +3,20 @@ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
3
  import { Avatar } from "./avatar";
4
4
  import { Text } from "./text";
5
5
  import type { AvatarSize } from "./avatar_size";
6
+ import { useLoticsLocale } from "./locale";
7
+
8
+ /**
9
+ * What a member with no usable name is CALLED — one answer, shared by every
10
+ * surface that renders a person, so a chip and the profile card it opens cannot
11
+ * name the same blank differently.
12
+ *
13
+ * The word comes in as a parameter rather than out of a hook: the fallback is
14
+ * pure string logic, and keeping it that way lets the rule be tested without a
15
+ * provider and reused where no hook may run.
16
+ */
17
+ export function memberDisplayName(name: string | null | undefined, unknown: string): string {
18
+ return name?.trim() || unknown;
19
+ }
6
20
 
7
21
  interface MemberChipProps {
8
22
  /** Display name. Empty/blank falls back to a neutral label — pass
@@ -20,6 +34,22 @@ interface MemberChipProps {
20
34
  * than as a current assignment. The chip keeps its height, because its usual
21
35
  * home is a fixed row: this is a colour change, never an extra line. */
22
36
  inactive?: boolean;
37
+ /**
38
+ * This chip is a DOOR — something opens when it is pressed. Underlines the
39
+ * name in neutral ink, the kit's marker for "leads somewhere, and not away"
40
+ * (see `TextLink` with no `href`).
41
+ *
42
+ * It exists because a pressable that paints nothing at rest is text: with no
43
+ * ground, no border and no underline, its only affordance is a hover wash,
44
+ * which does not exist on touch and has not happened yet for anyone who has
45
+ * not already pointed at it. The chip then measures perfectly, carries a real
46
+ * focus ring, and is never pressed.
47
+ *
48
+ * Set it ONLY when the chip itself opens something. A chip inside a row that
49
+ * navigates must not wear it — the row is the door, and marking the chip
50
+ * promises a second destination that is not there.
51
+ */
52
+ marker?: boolean;
23
53
  style?: StyleProp<ViewStyle>;
24
54
  }
25
55
 
@@ -45,9 +75,13 @@ export function MemberChip({
45
75
  secondary,
46
76
  size = "md",
47
77
  inactive,
78
+ marker,
48
79
  style,
49
80
  }: MemberChipProps) {
50
- const displayName = name?.trim() || "Unknown";
81
+ // `Avatar` has always taken this word from the pack; the NAME beside it was a
82
+ // hardcoded "Unknown", so a Vietnamese workspace drew initials from "Không rõ"
83
+ // and then printed "Unknown" next to them. One resolver, one word.
84
+ const displayName = memberDisplayName(name, useLoticsLocale().avatar.unknown);
51
85
  // THE TEXT FOLLOWS THE RUNG. `size` used to scale the avatar alone, so a chip
52
86
  // asked for the dense rung got a 24px avatar beside a 14px name — half-dense,
53
87
  // and the mismatch surfaces wherever the chip sits INSIDE a sentence: a 12px
@@ -58,6 +92,19 @@ export function MemberChip({
58
92
  // Only `sm` steps down, because only `sm` claims to be dense; every larger
59
93
  // rung keeps the body size, where a person's name belongs.
60
94
  const textSize = size === "sm" ? "xs" : "sm";
95
+ // ONE RUNG BELOW the name. It used to share the name's rung, so a chip's two
96
+ // lines were the same size AND the same weight and differed only in ink — the
97
+ // flattest a subject-over-supporting pair can be, and unreadable as a pair at
98
+ // a glance. `xs` is the ramp's floor, which is why the dense `sm` chip's two
99
+ // lines sit level: there is nothing under it to step to.
100
+ const secondarySize = "xs";
101
+ // TIGHT ONLY WHEN THERE IS A PAIR. Prose leading puts a 24px box around 14px
102
+ // text, so two stacked lines carried ~10px of empty box between the glyphs and
103
+ // read as two separate things rather than one person — see `Text.leading`,
104
+ // which exists for exactly this and asks for BOTH lines of the pair. A lone
105
+ // name is not a pair, and leaving it on prose leading keeps every single-line
106
+ // chip — every picker option, every cell — pixel-identical to before.
107
+ const pairLeading = secondary ? ("tight" as const) : undefined;
61
108
  return (
62
109
  <View style={[styles.row, style]}>
63
110
  <Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
@@ -65,13 +112,36 @@ export function MemberChip({
65
112
  <Text
66
113
  userSelect="none"
67
114
  size={textSize}
115
+ // MEDIUM, never semibold: a chip is a ROW SUBJECT, and semibold is the
116
+ // heading ladder's weight (`MemberProfileCard`'s name is a heading and
117
+ // takes it). At `regular` the name was indistinguishable from its own
118
+ // supporting line except by colour.
119
+ weight="medium"
120
+ leading={pairLeading}
121
+ // NEUTRAL underline, never the navigation blue: pressing this opens
122
+ // the person in place rather than travelling to them, and blue
123
+ // underlined text promises a destination.
124
+ decoration={marker ? "underline" : undefined}
68
125
  color={inactive ? "zinc-500" : undefined}
69
126
  numberOfLines={1}
70
127
  >
71
128
  {displayName}
72
129
  </Text>
73
130
  {secondary ? (
74
- <Text userSelect="none" size={textSize} color="zinc-500" numberOfLines={1}>
131
+ <Text
132
+ userSelect="none"
133
+ size={secondarySize}
134
+ leading={pairLeading}
135
+ // `muted`, the kit's supporting-text TOKEN, not the raw `zinc-500`
136
+ // this used to carry. Two things were wrong with the raw value: it
137
+ // is the minority spelling of a colour the kit names 137 times as
138
+ // `muted`, and `inactive` above resolves to zinc-500 too — so on a
139
+ // departed member the muted NAME and this line came out the same
140
+ // ink and the mute stopped signalling anything. An ink is a
141
+ // MEANING; two meanings may not share one.
142
+ color="muted"
143
+ numberOfLines={1}
144
+ >
75
145
  {secondary}
76
146
  </Text>
77
147
  ) : null}
@@ -0,0 +1,91 @@
1
+ import { StyleProp, ViewStyle } from "react-native";
2
+ import type { AvatarSize } from "./avatar_size";
3
+ import { MemberChip, memberDisplayName } from "./member_chip";
4
+ import { MemberProfileCard, type MemberProfileCardProps } from "./member_profile_card";
5
+ import { Peek } from "./peek";
6
+ import type { PopoverAlign, PopoverSide } from "./popover";
7
+ import { useLoticsLocale } from "./locale";
8
+
9
+ export interface MemberPeekProps extends MemberProfileCardProps {
10
+ /** The chip's rung on the shared avatar scale — the TRIGGER's size, not the
11
+ * card's (the card is always a profile header). Default `md`. */
12
+ size?: AvatarSize;
13
+ /** Optional secondary line on the CHIP. The card carries the full detail, so
14
+ * reach for this only where the resting row needs it on its own. */
15
+ secondary?: string | null;
16
+ side?: PopoverSide;
17
+ align?: PopoverAlign;
18
+ /** Override the trigger's announced name. Defaults to the locale's
19
+ * `memberProfile.profile(name)` — "Profile of Nguyễn Thị An". */
20
+ accessibilityLabel?: string;
21
+ style?: StyleProp<ViewStyle>;
22
+ }
23
+
24
+ /**
25
+ * A member chip that OPENS — press the person and their profile appears in
26
+ * place: bigger avatar, what they sign in as, their role, their teams.
27
+ *
28
+ * The composition is deliberately narrow (a `Peek` around a `MemberChip`, with
29
+ * `MemberProfileCard` as the body) and it is a named component anyway, because
30
+ * the three things a hand-rolled version gets wrong are invisible one at a
31
+ * time: the popover width, the announced name, and WHICH fields the card
32
+ * shows. Three call sites rolling their own give a workspace three different
33
+ * answers to "who is this".
34
+ *
35
+ * ### Where this must NOT go
36
+ *
37
+ * **Not inside a picker option.** `MemberSelect` and `InlineMemberSelect`
38
+ * render a `MemberChip` per option; a peek there pops an anchored popover over
39
+ * an anchored popover, at the one moment the reader is trying to CHOOSE rather
40
+ * than to browse. Option rows keep the plain chip.
41
+ *
42
+ * **Not inside a row that is itself pressable.** A `PressableRow` that opens a
43
+ * record, holding a chip that opens a card, puts two destinations behind one
44
+ * object — press the row, navigate; press four pixels left, peek — and the kit
45
+ * has removed that shape once already (see the reference field's interior
46
+ * verb, `composition.md`). Where a row already presses, let it press: the
47
+ * profile belongs on the record it opens.
48
+ *
49
+ * **Test the container, not its look.** "It is only a header" is not an answer:
50
+ * a `DataGrid` group band NAMES a person and reads perfectly inert, and it is a
51
+ * button that collapses the group — a peek there nests a button inside a button.
52
+ * Ask what the press would otherwise have done; if the answer is anything, the
53
+ * chip stays plain.
54
+ *
55
+ * That leaves the surfaces a peek is actually for: a person named in prose, a
56
+ * static header that does nothing, a detail row, a log or register whose ROWS do
57
+ * not navigate. In practice a fully interactive screen has none of them, and
58
+ * that is the correct outcome rather than a gap to work around.
59
+ *
60
+ * PURE — pass the member's fields in; this fetches nothing.
61
+ *
62
+ * ```tsx
63
+ * <MemberPeek name={m.name} image={m.image} identity={m.email} groups={m.groups} />
64
+ * ```
65
+ */
66
+ export function MemberPeek(props: MemberPeekProps) {
67
+ const locale = useLoticsLocale();
68
+ const { size = "md", secondary, side, align, accessibilityLabel, style, ...card } = props;
69
+ const displayName = memberDisplayName(card.name, locale.avatar.unknown);
70
+ const words = { ...locale.memberProfile, ...card.labels };
71
+ return (
72
+ <Peek
73
+ accessibilityLabel={accessibilityLabel ?? words.profile(displayName)}
74
+ content={<MemberProfileCard {...card} />}
75
+ side={side}
76
+ align={align}
77
+ >
78
+ <MemberChip
79
+ name={card.name}
80
+ image={card.image}
81
+ secondary={secondary}
82
+ size={size}
83
+ inactive={card.inactive}
84
+ // This chip IS the door — without the marker it paints nothing at rest
85
+ // and only a pointer already on it would ever learn that.
86
+ marker
87
+ style={style}
88
+ />
89
+ </Peek>
90
+ );
91
+ }
@@ -0,0 +1,261 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Avatar } from "./avatar";
4
+ import { DetailRow, DetailTable } from "./detail_row";
5
+ import { Badge } from "./badge";
6
+ import { Divider } from "./divider";
7
+ import { Text } from "./text";
8
+ import { memberDisplayName } from "./member_chip";
9
+ import { useLoticsLocale, useLocaleTag } from "./locale";
10
+ import { formatDate } from "./format_date";
11
+
12
+ /** The card's own words — see `LoticsLocale.memberProfile`. */
13
+ export interface MemberProfileLabels {
14
+ /** Row label for the organization role. */
15
+ role?: string;
16
+ /** Row label for the member's groups — the platform's "department". */
17
+ groups?: string;
18
+ /** Row label for when this person joined the organization. */
19
+ joined?: string;
20
+ /** Stands in for the groups VALUE when the member belongs to none. */
21
+ noGroups?: string;
22
+ /** Said under the name when this person has left the organization. */
23
+ inactive?: string;
24
+ /** Screen-reader name of the trigger that opens this card — used by
25
+ * {@link MemberProfileCard}'s companion `MemberPeek`, which is why it lives
26
+ * in this slice rather than Peek's: the words describe the CARD, not the
27
+ * popover mechanism. */
28
+ profile?: (name: string) => string;
29
+ }
30
+
31
+ export interface MemberProfileCardProps {
32
+ /** Display name. Blank falls back to the locale's unknown label, exactly as
33
+ * `MemberChip` does — the two render the same person. */
34
+ name?: string | null;
35
+ /** Avatar image URL. Absent → initials. */
36
+ image?: string | null;
37
+ /**
38
+ * What this person signs in as — an email address for a self-managed member,
39
+ * an administrator-issued name for an admin-managed one. Not labelled
40
+ * "Email" anywhere in the card, because for half the accounts in a workspace
41
+ * it is not one and cannot receive mail.
42
+ */
43
+ identity?: string | null;
44
+ /**
45
+ * The organization role, **already translated for display** ("Quản trị
46
+ * viên") — never the raw `owner`/`admin`/`member` enum.
47
+ *
48
+ * The kit carries no domain vocabulary and no i18n logic, so a role arrives
49
+ * as the word the product decided to show. It is also the one field here
50
+ * that a reader routinely mis-reads: a role is a PERMISSION level, not
51
+ * seniority, and "Admin" beside a name on a sales register says nothing
52
+ * about who that person is in the company. Pass it where it means
53
+ * something; omit it (the row disappears) where it does not.
54
+ */
55
+ role?: string | null;
56
+ /**
57
+ * The member's group names — what an org actually uses to say Sale, Kế toán,
58
+ * CSKH.
59
+ *
60
+ * ABSENT ≠ EMPTY, and the card renders the difference rather than flattening
61
+ * it: `undefined` drops the row entirely (this caller was never told — a
62
+ * public app response, an older server), while `[]` keeps the row and says
63
+ * so. Collapsing the two turns "we do not know" into the confident claim
64
+ * "they are on no team".
65
+ */
66
+ groups?: readonly string[] | null;
67
+ /**
68
+ * When this person joined the organization, as an ISO date or timestamp.
69
+ *
70
+ * A DATE, not a formatted string — unlike `role`, which is vocabulary only the
71
+ * product can translate, a date has a canonical form the kit already owns, so
72
+ * it renders through `formatDate` under the active locale. Two hosts left to
73
+ * format it themselves would drift ("March 2024" against "03/2024") for a
74
+ * value that means the same thing.
75
+ *
76
+ * Rendered as MONTH AND YEAR. The question it answers is "is this the new
77
+ * person?", and a day is more precision than that asks for — it also stops
78
+ * the row reading as an event the reader is meant to act on.
79
+ */
80
+ joined?: string | null;
81
+ /** This person has left the organization — shows the state as a badge and
82
+ * mutes the name, so a record that still names them reads as history. */
83
+ inactive?: boolean;
84
+ /** ONE door to the fuller record ("View profile"). A peek that needs a second
85
+ * verb wanted to be a screen. */
86
+ action?: ReactNode;
87
+ /** Per-instance overrides; anything omitted comes from the active locale. */
88
+ labels?: MemberProfileLabels;
89
+ }
90
+
91
+ /**
92
+ * THE CARD'S THREE TEXT CLASSES, named once so every line below picks from a
93
+ * system instead of being decided on its own.
94
+ *
95
+ * This is here because deciding per element is how the card ended up with four
96
+ * type sizes inside 200px, each defensible alone: a heading rung, a value rung,
97
+ * an identity line one step under the name, and a label rung "for a compact
98
+ * peek". Every one had a reason and the set had no shape, so each fix to one
99
+ * element left a neighbour unaligned and the surface never converged.
100
+ *
101
+ * SUBJECT the person — the only heading here
102
+ * VALUE any fact's content: the identity line, a row's value
103
+ * SUPPORTING anything that DESCRIBES a value rather than being one
104
+ *
105
+ * SUPPORTING shares VALUE's rung and separates by INK. In a label/value ROW the
106
+ * label is already identified by its column, so a size step would be a third
107
+ * signal for a distinction position and ink already carry — unlike a STACKED
108
+ * pair (see `MemberChip`), where top-to-bottom reading needs the step to say
109
+ * which line leads.
110
+ */
111
+ const SUBJECT = { size: "lg", weight: "semibold" } as const;
112
+ const VALUE = { size: "sm" } as const;
113
+ const SUPPORTING = { size: "sm", color: "muted" } as const;
114
+
115
+ /**
116
+ * WHO IS THIS PERSON — a bigger avatar, their name, what they sign in as, their
117
+ * role and their teams, in one card that reads identically everywhere.
118
+ *
119
+ * It is the body `MemberPeek` reveals, and the reason that body is a component
120
+ * rather than four lines at each call site: the interesting failure of a
121
+ * hand-composed profile is not that it looks different, it is that each copy
122
+ * picks a different SUBSET — one shows the email, the next the role, a third
123
+ * neither — so the same colleague answers a different question depending on
124
+ * which screen you met them on. That is the defect `AvatarSize` was written to
125
+ * fix for pixels, one level up.
126
+ *
127
+ * PURE: pass the fields in (an app feeds a `ResolvedMember`, the product feeds
128
+ * its member directory); this fetches nothing and knows no domain types.
129
+ *
130
+ * Rows appear only when their field does — a card with no role and no groups is
131
+ * a header and nothing else, which is the correct rendering of "that is all we
132
+ * were told", not a degraded one.
133
+ *
134
+ * ```tsx
135
+ * <MemberProfileCard
136
+ * name={m.name}
137
+ * image={m.image}
138
+ * identity={m.email}
139
+ * groups={m.groups}
140
+ * action={<Button title="View profile" color="secondary" onPress={open} />}
141
+ * />
142
+ * ```
143
+ */
144
+
145
+ export function MemberProfileCard(props: MemberProfileCardProps) {
146
+ const locale = useLoticsLocale();
147
+ const words = { ...locale.memberProfile, ...props.labels };
148
+ const { name, image, identity, role, groups, joined, inactive, action } = props;
149
+ const localeTag = useLocaleTag();
150
+
151
+ const displayName = memberDisplayName(name, locale.avatar.unknown);
152
+ // An empty string is a value nobody chose — a role that failed to resolve, an
153
+ // identity that came back blank. Treated as absent so the row disappears
154
+ // rather than rendering a labelled void.
155
+ const roleText = role?.trim() || null;
156
+ const identityText = identity?.trim() || null;
157
+ // `null` joins `undefined` in meaning "not told"; only a real array is data.
158
+ const groupList = groups ?? null;
159
+ // An unparseable date yields "" from `formatDate`, which drops the row rather
160
+ // than printing an empty labelled slot — same rule as an empty role.
161
+ const joinedText = joined ? formatDate(joined, { format: "monthYear", locale: localeTag }) : "";
162
+ const hasRows = roleText !== null || groupList !== null || joinedText !== "";
163
+
164
+ return (
165
+ <View style={styles.card}>
166
+ <View style={styles.header}>
167
+ {/* `xl` is the rung documented for "a profile's own header", and this is
168
+ one. The chip that opens this card is `md` (28) — a preview whose
169
+ avatar is the same size as its trigger's has not previewed
170
+ anything. */}
171
+ <Avatar size="xl" name={displayName} source={image ? { uri: image } : undefined} />
172
+ <View style={styles.identity}>
173
+ {/* SUBJECT — the card's one heading, and its whole hierarchy. At body
174
+ size it was separated from its own identity line by WEIGHT alone,
175
+ which is a separate font file rather than a step, and the surface
176
+ measured 1.17x — flatter than a register with no page band. */}
177
+ <Text {...SUBJECT} color={inactive ? "zinc-500" : undefined} numberOfLines={2}>
178
+ {displayName}
179
+ </Text>
180
+ {identityText ? (
181
+ <Text {...SUPPORTING} numberOfLines={2}>
182
+ {identityText}
183
+ </Text>
184
+ ) : null}
185
+ {inactive ? (
186
+ // A BADGE, not another muted line. Departed is a lifecycle STATE and
187
+ // this is the one status the card can carry, which is precisely what
188
+ // a badge is for — as a `SUPPORTING` line it wore the identical
189
+ // treatment to the email above it, so a state and a piece of
190
+ // metadata read as the same kind of thing and the state lost.
191
+ //
192
+ // No `color`: departed is neutral, not a danger. `alignSelf` because
193
+ // a tonal pill stretches to its container otherwise — see `Badge`.
194
+ <Badge variant="tonal" label={words.inactive} style={styles.status} />
195
+ ) : null}
196
+ </View>
197
+ </View>
198
+
199
+ {hasRows ? (
200
+ <>
201
+ <Divider />
202
+ {/* A SHORT label column, and it is load-bearing rather than taste:
203
+ `DetailTable` stacks itself when the columns would crush the value
204
+ below `minValueWidth` (160), and the kit's default label column
205
+ (130) plus that floor plus the gutter exceeds the width a peek has
206
+ to give. At the default every row in this card would render
207
+ stacked — correct-looking in isolation, and quietly nothing like
208
+ the same card in a drawer. */}
209
+ <DetailTable labelWidth={64} minHeight={24}>
210
+ {roleText !== null ? (
211
+ <DetailRow label={words.role} flat>
212
+ <Text {...VALUE}>{roleText}</Text>
213
+ </DetailRow>
214
+ ) : null}
215
+ {joinedText !== "" ? (
216
+ <DetailRow label={words.joined} flat>
217
+ <Text {...VALUE}>{joinedText}</Text>
218
+ </DetailRow>
219
+ ) : null}
220
+ {groupList !== null ? (
221
+ <DetailRow label={words.groups} flat>
222
+ {groupList.length > 0 ? (
223
+ // Joined, not one chip per group: a group is a CATEGORY, and
224
+ // `Badge`'s own contract reserves it for status ("if you're
225
+ // adding a Badge to show more info, that's text"). Text also
226
+ // wraps predictably where a pill row reflows.
227
+ <Text {...VALUE}>{groupList.join(", ")}</Text>
228
+ ) : (
229
+ // The one place a VALUE wears the supporting ink: "None" is
230
+ // the absence of a value, not one.
231
+ <Text {...SUPPORTING}>{words.noGroups}</Text>
232
+ )}
233
+ </DetailRow>
234
+ ) : null}
235
+ </DetailTable>
236
+ </>
237
+ ) : null}
238
+
239
+ {action ? (
240
+ <>
241
+ <Divider />
242
+ {action}
243
+ </>
244
+ ) : null}
245
+ </View>
246
+ );
247
+ }
248
+
249
+ const styles = StyleSheet.create({
250
+ card: { gap: 10 },
251
+ // Centered: the avatar is the tallest thing here by a wide margin, and a
252
+ // top-aligned two-line name beside a 72px circle reads as a caption that
253
+ // slipped.
254
+ header: { flexDirection: "row", alignItems: "center", gap: 12 },
255
+ identity: { flex: 1, minWidth: 0, gap: 1, alignItems: "flex-start" },
256
+ // The pill sits on its own line under the identity rather than beside the
257
+ // name: at this width a 20px name plus a tonal pill overruns the 236px text
258
+ // column and wraps, and a status that wraps under its own subject reads as a
259
+ // third line rather than as the subject's state.
260
+ status: { marginTop: 4 },
261
+ });
@@ -26,7 +26,8 @@ export interface RecordSummaryMetric {
26
26
  }
27
27
 
28
28
  export interface RecordSummaryProps {
29
- /** The record's id/name — xl semibold, tabular so ids align across records. */
29
+ /** The record's id/name — `xxl` semibold (the ramp's `#`), tabular so ids
30
+ * align across records. */
30
31
  title: string;
31
32
  /** Heading level of the title (`role="header"` + aria-level). Default 1 —
32
33
  * the record page's one h1; pass 2 when the band sits inside a surface