@lotics/ui 44.6.0 → 44.7.1

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
@@ -75,6 +75,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
75
75
  `DetailRow` — the three annotation slots carry the three urgency levels exactly. A binary
76
76
  `error={overdue ? … }` is the tell: it makes a date due tomorrow read like one due in three
77
77
  months. → [catalog.md §Dates & times](./docs/catalog.md).
78
+ - **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** `@lotics/ui/brand_mark`
79
+ draws Facebook / Zalo / LinkedIn / Google Meet as solid single-colour silhouettes — a reader
80
+ scanning a feed recognises the logo before the word. The names are an allowlist, so a channel
81
+ with no mark (a phone call, an event, a website) fails to typecheck and falls back to `Icon`
82
+ instead of borrowing another company's logo. → [catalog.md §Text & formatting](./docs/catalog.md).
78
83
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
79
84
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
80
85
  - **Hand-typed type is off-system — and it always lands too small.** Every run of language is
@@ -84,6 +89,12 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
84
89
  the only way to get the letter-spacing curve and Inter's `cv11 ss01 ss03` alternates, the
85
90
  "designed" tells you cannot type by hand. **Grep the diff: `fontSize:` / `lineHeight:` /
86
91
  `letterSpacing:` outside `src/` is a bug.** → [composition.md §Typography](./docs/composition.md).
92
+ - **A label is an `Eyebrow` or a `GroupLead`, and the difference is WHAT IT LABELS.** A value
93
+ gets `<Eyebrow>` (`xs` muted medium, dense surfaces, never uppercase); a BLOCK someone acts on
94
+ gets `<GroupLead>` (`sm` medium, page ink). Neither takes `size`/`weight`/`transform` — the
95
+ omitted props are the rule. Hand-writing the `Text` yourself is how an act-on-it title ends up
96
+ whispering at 12px, which is also where stacked diacritics crowd. →
97
+ [composition.md §Eyebrow vs group lead](./docs/composition.md).
87
98
  - **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
88
99
  callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
89
100
  - **A button never contains a control** — a row that presses open AND carries its own controls
package/docs/catalog.md CHANGED
@@ -430,6 +430,22 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
430
430
  numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
431
431
  box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
432
432
  its annotation; see composition.md, which also covers the `gap` that goes with it.
433
+ - **`eyebrow`** — `Eyebrow`: the small quiet label above or beside a VALUE (`xs` muted medium) —
434
+ a metric caption, a field name in a cell, an artifact tag. Takes `color` (for a VERDICT word
435
+ like "Mismatch") and `align`, and deliberately takes no `size`, `weight` or `transform`:
436
+ all-caps is banned and a bigger eyebrow is a different rung. Calibrated for DENSE surfaces.
437
+ - **`group_lead`** — `GroupLead`: the label that titles a BLOCK (`sm` medium in the page's ink,
438
+ `size="md"` for a major sub-part) — "Delivery" over an address stack, "Next action" over the
439
+ paragraph someone acts on. No `weight`: semibold is the heading rung, so reach for
440
+ `SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
441
+ value; composition.md § Eyebrow vs group lead has the table.
442
+ - **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
443
+ happened on — `facebook`, `zalo`, `linkedin`, `google-meet`. Solid single-colour silhouettes in
444
+ each brand's own hex, not the full-colour logos: a byline draws these at 14–20px and a
445
+ five-colour logo turns to mud there. `size` (default 16) and `muted` (draw in the text colour).
446
+ The name list is an ALLOWLIST — a channel with no mark will not typecheck, so the call site
447
+ falls back to a generic `Icon` rather than rendering the wrong company's logo. Not for the app's
448
+ own glyphs; that is `icon`.
433
449
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
434
450
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
435
451
  native; takes a markdown `children` string. **`variant`** decides whose type
@@ -134,7 +134,12 @@ restyle a heading level per-page.
134
134
  hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
135
135
  to its own section-level heading just to separate it.
136
136
  - **Eyebrow vs group lead — a label is one or the other, and the difference is what it
137
- LABELS.** Both are hand-written `Text`; picking by feel is how a title ends up whispering.
137
+ LABELS.** Each is a COMPONENT `@lotics/ui/eyebrow` and `@lotics/ui/group_lead` so the call
138
+ site DECLARES which one it is rather than re-deriving a rung from feel, which is how a title
139
+ ends up whispering. They were hand-written `Text` and this section told you to keep it that
140
+ way; a shipped app then labelled three act-on-it blocks at the eyebrow's `xs` muted, which is
141
+ the failure the prose could not prevent. Neither takes a `size`, `weight` or `transform`: the
142
+ props a component omits are the rule it enforces.
138
143
 
139
144
  | | Eyebrow | Group lead |
140
145
  |---|---|---|
@@ -148,14 +153,18 @@ restyle a heading level per-page.
148
153
  an afterthought: there are no siblings for it to be quiet among. A label that titles a block
149
154
  someone must ACT on is a group lead, and `sm medium` is its floor.
150
155
 
151
- - **Eyebrow** — `<Text size="xs" color="muted" weight="medium">`, **sentence case, NEVER
152
- `transform="uppercase"`**: a small quiet label above or beside a VALUE — an artifact tag
153
- ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
154
- label. **All-caps is banned** — it reads as shouting, and reflexively uppercasing every little
155
- label is the #1 thing that makes a surface feel templated. Sentence case + medium weight, full
156
- stop. (A COLORED status word — a verdict like "Mismatch" / "Resolved" — keeps the same xs/medium
157
- shape with a status `color`, still not uppercase.) This applies to every hand-written label;
158
- don't reach for a wrapper component either just write the `Text`.
156
+ - **Eyebrow** — `<Eyebrow>` (`xs` muted medium), **sentence case, NEVER uppercase**: a small
157
+ quiet label above or beside a VALUE — an artifact tag ("Proposed", "Question", "Suggested
158
+ edit"), a field name, a metric caption, a minor one-line label. **All-caps is banned** — it
159
+ reads as shouting, and reflexively uppercasing every little label is the #1 thing that makes a
160
+ surface feel templated. There is no `transform` prop to reach for. (A COLORED status word a
161
+ verdict like "Mismatch" / "Resolved" — keeps the rung and the weight and takes a status
162
+ `color`; colour here is what the word MEANS, never emphasis.)
163
+ - **Group lead** `<GroupLead>` (`sm` medium in the page's own ink, `size="md"` where the block
164
+ is a major sub-part). The label over a BLOCK: "Deciding factor" over the fact it names,
165
+ "Delivery" over an address stack, "Next action" over the paragraph someone acts on. No
166
+ `weight` prop — semibold is the heading rung, and a group lead reaching for it is a heading
167
+ wearing the wrong name. Reach for `SubsectionHeading` instead.
159
168
  - **Gate header** — a `Dialog` uses `DialogHeaderTitle`; a popover form uses
160
169
  `Text size="sm" weight="semibold"` + an optional xs muted subtitle.
161
170
 
@@ -367,6 +376,13 @@ a dashboard. Card stat rails use `KPICard`.
367
376
  ## Numbers, money, dates
368
377
 
369
378
  - Free-standing numerals: `<Text tabular>` — always.
379
+ - **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
380
+ header band and leave the cells bare. The corollary is the one that bites: a label/value list
381
+ whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
382
+ value carries its own — and a count sharing a column with a currency at the same size, weight
383
+ and right edge is unreadable however well the labels are worded, because the eye reads a column
384
+ of figures as one series. Where the mixed units are parallel across many rows, they are not one
385
+ column at all: give each its own, and the band names both.
370
386
  - **Money**: `formatMoney(n)` from `@lotics/ui/format_money` (`compact` abbreviates values
371
387
  ≥ 1,000,000 for strip captions). **Warning:** the full-value path formats via `Intl`, whose
372
388
  output separates the currency symbol with a non-breaking space (the compact path uses a plain
@@ -1214,6 +1230,13 @@ column of them. Dropped to `xs` and lightened past `muted` it stops being legibl
1214
1230
  reading as a caption that lost its value — small type is not the same as quiet type. The active
1215
1231
  SORTED column steps up in ink, never in weight; semibold belongs to the heading ladder.
1216
1232
 
1233
+ **A fixed column `width` is sized for the LONGEST locale, not the one you typed it in.** Every
1234
+ header is a translated string, and the same label runs half again as long in Vietnamese as in
1235
+ English — so a width measured against the source truncates the header in the language the app
1236
+ actually ships in, on a column the reader was told to check. It fails silently in review because
1237
+ the reviewer reads the source locale. Size against the longest translation, or give the column
1238
+ `flex` and let it take what it needs.
1239
+
1217
1240
  **Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
1218
1241
  the #1 templated tell; `Table` and `SortHeader` were uppercasing every column label in every app,
1219
1242
  which is the loudest possible version of the thing the rule exists to stop. Removing the case
package/docs/reviewing.md CHANGED
@@ -222,6 +222,16 @@ spacing distinguishes them.
222
222
  offsets is the signature. Fix by bounding the child that varies — never with a cross-axis knob
223
223
  (it moves the content and leaves the chrome), and never by resizing the CONTAINER, which changes
224
224
  both terms and leaves the spread.
225
+ - **A VARIABLE-width leading slot destroys the column behind it.** A row's lead is sized for an
226
+ icon; put a chip, a badge or a name in it and every row's title starts at a different x. Collect
227
+ one repeated cell's `left` down the rows — more than one distinct value and the column does not
228
+ exist, however aligned each row looks on its own. The spread is small enough to read as
229
+ sloppiness rather than as a defect, which is why it survives review; the fix is a fixed-width
230
+ lead, or a register, where columns make it impossible.
231
+ - **Every numeric column: is its unit named anywhere, and is it ONE unit?** Collect the leaf values
232
+ sharing a right edge, with their size and weight. Identical treatment across values that are not
233
+ the same KIND of thing — a count beside a currency — is the signature, and the labels beside them
234
+ do not rescue it. → [composition.md](./composition.md) §"Numbers, money, dates".
225
235
  - **A slot holding another component's part must be sized by that component's constant** — a
226
236
  24px mark in an 18px column sits 3px outside the surface.
227
237
  - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
@@ -1,6 +1,8 @@
1
1
  import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
2
2
  import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
+ import { GroupLead } from "@lotics/ui/group_lead";
5
+ import { BrandMark, type BrandName } from "@lotics/ui/brand_mark";
4
6
  import { colors, solid, withAlpha } from "@lotics/ui/colors";
5
7
  import { DRAWER_GUTTER } from "@lotics/ui/drawer";
6
8
  import { Button } from "@lotics/ui/button";
@@ -409,6 +411,14 @@ interface ActivityEntry {
409
411
  gist?: string;
410
412
  /** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
411
413
  over: string;
414
+ /**
415
+ * The channel's own mark, where the channel is an outside brand we can name.
416
+ * `kind` says WHAT happened and picks the fallback glyph; this says WHERE, and
417
+ * a reader scanning a feed recognises the logo before they read the word.
418
+ * Omit it for a phone call, an in-person event, a plain note — those have no
419
+ * brand, and the kind glyph is the honest mark for them.
420
+ */
421
+ brand?: BrandName;
412
422
  when: string;
413
423
  /** An unanswered outreach is a real state and reads differently from a reply. */
414
424
  awaiting?: boolean;
@@ -540,9 +550,25 @@ const ACTIVITY: ActivityEntry[] = [
540
550
  kind: "video",
541
551
  gist: "Demo — the reconciliation step is what sold it; pricing still open",
542
552
  over: "Google Meet",
553
+ brand: "google-meet",
543
554
  when: "Today, 10:15",
544
555
  media: { src: "/sample-video.webm", label: "Demo recording" },
545
556
  transcript: TRANSCRIPT,
557
+ // THE ENTRY THAT CARRIES EVERYTHING — a recording, its transcript, a machine
558
+ // reading of it, AND the files that changed hands. Four blocks, and the only
559
+ // thing keeping them one entry rather than a pile is that every file on the
560
+ // stack is drawn at the SAME size by the SAME component.
561
+ //
562
+ // `FileThumbnailGrid` has two sizing modes and the DEFAULT is not the small
563
+ // one: omit `itemSize` and it switches to FILL, deriving columns so the row
564
+ // spans the container — two files in a 500px column become two 250px tiles.
565
+ // Put that beside a sibling block at `THUMBNAIL_SIZE` (96) and the same file
566
+ // set reads as two unrelated things. Every file surface in this template and
567
+ // in chat passes `itemSize={THUMBNAIL_SIZE}` for exactly that reason.
568
+ files: [
569
+ { id: "q1", filename: "quote-2026-0418.pdf", mimeType: "application/pdf", url: "#" },
570
+ { id: "q2", filename: "rate-card.xlsx", mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", url: "#" },
571
+ ],
546
572
  // NO human note on this entry, deliberately. It carried "the person who
547
573
  // maintains it was not in the room" — which the summary's own Risk line says
548
574
  // better, and which the participants line was gesturing at too: one fact,
@@ -602,6 +628,7 @@ const ACTIVITY: ActivityEntry[] = [
602
628
  kind: "message",
603
629
  gist: "Asked in the forwarders' group who handles Japan customs paperwork",
604
630
  over: "Facebook Group",
631
+ brand: "facebook",
605
632
  when: "10 Aug, 21:30",
606
633
  files: [
607
634
  { id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
@@ -719,7 +746,11 @@ function ActivityEntryRow({
719
746
  backgroundColor: withAlpha(accent, 0.1),
720
747
  }}
721
748
  >
722
- <Icon name={ACTIVITY_ICON[a.kind]} size={16} color={accent} />
749
+ {a.brand ? (
750
+ <BrandMark name={a.brand} size={16} />
751
+ ) : (
752
+ <Icon name={ACTIVITY_ICON[a.kind]} size={16} color={accent} />
753
+ )}
723
754
  </View>
724
755
 
725
756
  <View style={{ flex: 1, gap: 8 }}>
@@ -930,7 +961,7 @@ function ActivityEntryRow({
930
961
  // above. Provenance is weighted by consequence: name the source where it
931
962
  // is NOT on screen.
932
963
  <Inset>
933
- <Text size="xs" color="muted" weight="medium">AI summary</Text>
964
+ <GroupLead>AI summary</GroupLead>
934
965
  <Markdown variant="embedded">{a.bodyByAi}</Markdown>
935
966
  </Inset>
936
967
  ) : null}
@@ -3173,18 +3204,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3173
3204
  shape every comment UI converges on: a metadata header, the prose
3174
3205
  under it, the row's actions at the TOP RIGHT beside the text they
3175
3206
  act on, and heavy artifacts behind their own disclosure. */}
3176
- <View style={{ gap: 20 }}>
3177
- {(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a) => (
3178
- <ActivityEntryRow
3179
- key={a.key}
3180
- a={a}
3181
- onEdit={(patch) =>
3182
- setActivity((prev) =>
3183
- prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
3184
- )
3185
- }
3186
- onDelete={() => setActivity((prev) => prev.filter((e) => e.key !== a.key))}
3187
- />
3207
+ {/* SPACE AND A RULE, because an entry here is not a row.
3208
+ Between entries the beat is 32; INSIDE one the blocks sit at 8.
3209
+ Space alone was 20 against 8 — only 2.5x — and it was the same
3210
+ 20 whether the neighbour was 110px or 756px, so the separation
3211
+ never scaled with the thing it separated. An entry carrying a
3212
+ recording, a transcript, a summary and its files runs close to a
3213
+ screenful, and past a screenful a gap reads as one more gap: the
3214
+ reader cannot tell "next touchpoint" from "next block of this
3215
+ one". That is the same LENGTH discriminator `SubsectionStack`
3216
+ takes `divided` for, and it is why the hairline is allowed here
3217
+ even though rules otherwise belong to the section altitude.
3218
+ Beating an 8px inner gap on space alone would need ~48, which
3219
+ then sets the one-line entries adrift. */}
3220
+ <View style={{ gap: 32 }}>
3221
+ {(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a, i, shown) => (
3222
+ <View key={a.key} style={{ gap: 32 }}>
3223
+ <ActivityEntryRow
3224
+ a={a}
3225
+ onEdit={(patch) =>
3226
+ setActivity((prev) =>
3227
+ prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
3228
+ )
3229
+ }
3230
+ onDelete={() => setActivity((prev) => prev.filter((e) => e.key !== a.key))}
3231
+ />
3232
+ {/* Not after the last: a trailing rule reads as the start of
3233
+ whatever comes next, and what comes next is the fold. */}
3234
+ {i < shown.length - 1 ? <Divider /> : null}
3235
+ </View>
3188
3236
  ))}
3189
3237
  </View>
3190
3238
  {/* The tail FOLDS rather than paging. A record's feed is read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.6.0",
3
+ "version": "44.7.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -103,6 +103,8 @@
103
103
  "./select": "./src/select.tsx",
104
104
  "./option_list": "./src/option_list.tsx",
105
105
  "./text": "./src/text.tsx",
106
+ "./eyebrow": "./src/eyebrow.tsx",
107
+ "./group_lead": "./src/group_lead.tsx",
106
108
  "./activity_indicator": "./src/activity_indicator.tsx",
107
109
  "./agent_run": "./src/agent_run.tsx",
108
110
  "./agent_progress": "./src/agent_progress.tsx",
@@ -130,6 +132,7 @@
130
132
  "./choice_list": "./src/choice_list.tsx",
131
133
  "./sources": "./src/sources.tsx",
132
134
  "./icon": "./src/icon.tsx",
135
+ "./brand_mark": "./src/brand_mark.tsx",
133
136
  "./dynamic_icon": {
134
137
  "react-native": "./src/dynamic_icon.tsx",
135
138
  "default": "./src/dynamic_icon.web.tsx"
@@ -0,0 +1,79 @@
1
+ import React from "react";
2
+ import { Svg, Path } from "react-native-svg";
3
+
4
+ /**
5
+ * The channels we can name. Anything NOT on this list has no brand mark and
6
+ * takes a generic `Icon` — a phone call, an in-person event, a website, an
7
+ * email that is not tied to one provider. Inventing a mark for those is how a
8
+ * byline ends up claiming a channel the record does not carry.
9
+ */
10
+ export type BrandName = "facebook" | "zalo" | "linkedin" | "google-meet";
11
+
12
+ interface Mark {
13
+ /** The brand's own colour. A mark drawn in anything else is not the mark. */
14
+ color: string;
15
+ path: string;
16
+ }
17
+
18
+ /**
19
+ * SOLID SINGLE-COLOUR MARKS, not the full-colour logos.
20
+ *
21
+ * A byline draws these at 14–20px, and a five-colour Gmail envelope or a
22
+ * four-colour Meet camera turns to mud at that size — the shape stops being
23
+ * recognisable, which is the entire job. One silhouette in the brand's colour
24
+ * survives the size and reads at a glance in a list.
25
+ *
26
+ * Path data is Simple Icons (CC0), so the geometry carries no licence of its
27
+ * own; the marks themselves stay the trademarks of their owners and are used
28
+ * here only to NAME the channel a touchpoint happened on.
29
+ */
30
+ const MARKS: Record<BrandName, Mark> = {
31
+ facebook: {
32
+ color: "#0866FF",
33
+ path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
34
+ },
35
+ zalo: {
36
+ color: "#0068FF",
37
+ path: "M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z",
38
+ },
39
+ linkedin: {
40
+ color: "#0A66C2",
41
+ path: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
42
+ },
43
+ "google-meet": {
44
+ color: "#00897B",
45
+ path: "M5.53 2.13 0 7.75h5.53zm.398 0v5.62h7.608v3.65l5.47-4.45c-.014-1.22.031-2.25-.025-3.46-.148-1.09-1.287-1.47-2.236-1.36zM23.1 4.32c-.802.295-1.358.995-2.047 1.49-2.506 2.05-4.982 4.12-7.468 6.19 3.025 2.59 6.04 5.18 9.065 7.76 1.218.671 1.428-.814 1.328-1.64v-13a.828.828 0 0 0-.877-.825zM.038 8.15v7.7h5.53v-7.7zm13.577 8.1H6.008v5.62c3.864-.006 7.737.011 11.58-.009 1.02-.07 1.618-1.12 1.468-2.07v-2.51l-5.47-4.68v3.65zm-13.577 0c.02 1.44-.041 2.88.033 4.31.162.948 1.158 1.43 2.047 1.31h3.464v-5.62z",
46
+ },
47
+ };
48
+
49
+ export interface BrandMarkProps {
50
+ name: BrandName;
51
+ /** Edge length in px. Defaults to 16 — the size that sits on a byline line. */
52
+ size?: number;
53
+ /**
54
+ * Draw in the current text colour instead of the brand's. For a surface where
55
+ * a run of coloured marks would compete with the content beside them.
56
+ */
57
+ muted?: boolean;
58
+ testID?: string;
59
+ }
60
+
61
+ /**
62
+ * A third-party channel's mark — the logo that says WHERE a touchpoint
63
+ * happened, at a glance, in a list of them.
64
+ *
65
+ * Not a general icon: `Icon` owns the curated glyph set and everything drawn in
66
+ * the app's own language. This is the narrow set of OUTSIDE brands we identify,
67
+ * and it is deliberately an allowlist — a name that is not in `MARKS` will not
68
+ * typecheck, so a channel with no mark falls back to a generic icon at the call
69
+ * site rather than silently rendering the wrong company's logo.
70
+ */
71
+ export function BrandMark(props: BrandMarkProps) {
72
+ const { name, size = 16, muted, testID } = props;
73
+ const mark = MARKS[name];
74
+ return (
75
+ <Svg width={size} height={size} viewBox="0 0 24 24" testID={testID}>
76
+ <Path d={mark.path} fill={muted ? "currentColor" : mark.color} />
77
+ </Svg>
78
+ );
79
+ }
package/src/clarify.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "./text";
3
+ import { Eyebrow } from "./eyebrow";
3
4
  import { ChoiceList } from "./choice_list";
4
5
 
5
6
  export interface ClarifyOption {
@@ -44,9 +45,9 @@ export function Clarify(props: ClarifyProps) {
44
45
  text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
45
46
  <View style={{ gap: 4 }}>
46
47
  {props.eyebrow ? (
47
- <Text size="xs" color="muted" weight="medium">
48
+ <Eyebrow>
48
49
  {props.eyebrow}
49
- </Text>
50
+ </Eyebrow>
50
51
  ) : null}
51
52
  <Text size="sm" weight="medium">
52
53
  {props.question}
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { Text } from "./text";
3
+ import type { TextColor } from "./text_utils";
4
+
5
+ export interface EyebrowProps {
6
+ children?: React.ReactNode;
7
+ /**
8
+ * A VERDICT carries its judgement in ink — "Mismatch" in danger, "Resolved" in
9
+ * success — and keeps the rung and the weight. Everything else stays muted:
10
+ * colour here is what the word MEANS, never emphasis.
11
+ */
12
+ color?: TextColor;
13
+ /** Alignment is layout, not rung — a right-aligned eyebrow is still an eyebrow. */
14
+ align?: "left" | "center" | "right";
15
+ numberOfLines?: number;
16
+ testID?: string;
17
+ }
18
+
19
+ /**
20
+ * A small quiet label above or beside a VALUE — a metric's caption, a field name
21
+ * in a cell, an artifact tag ("Proposed", "Question"). `xs` muted medium.
22
+ *
23
+ * Calibrated for DENSE surfaces, where a 12px label sits among dozens of
24
+ * siblings and quiet is the point. A label that titles a BLOCK is a
25
+ * `GroupLead` instead, and picking by feel is how a title ends up whispering —
26
+ * which is why these are two components rather than two ways of writing `Text`.
27
+ *
28
+ * There is no `transform` prop, and that is the enforcement: all-caps reads as
29
+ * shouting, and reflexively uppercasing every little label is the single thing
30
+ * that makes a surface feel templated. Sentence case, always.
31
+ *
32
+ * There is no `weight` or `size` either. A bigger or heavier eyebrow is a
33
+ * different rung with a different name.
34
+ */
35
+ export function Eyebrow(props: EyebrowProps) {
36
+ const { children, color = "muted", align, numberOfLines, testID } = props;
37
+ return (
38
+ <Text size="xs" color={color} weight="medium" align={align} numberOfLines={numberOfLines} testID={testID}>
39
+ {children}
40
+ </Text>
41
+ );
42
+ }
package/src/funnel.tsx CHANGED
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
2
2
  import { View, StyleSheet } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
+ import { Eyebrow } from "./eyebrow";
5
6
  import { PressableHighlight } from "./pressable_highlight";
6
7
 
7
8
  export interface FunnelStage {
@@ -104,9 +105,9 @@ export function Funnel(props: FunnelProps) {
104
105
  };
105
106
 
106
107
  const titleNode = title ? (
107
- <Text size="xs" color="muted" weight="medium">
108
+ <Eyebrow>
108
109
  {title}
109
- </Text>
110
+ </Eyebrow>
110
111
  ) : null;
111
112
 
112
113
  if (orientation === "vertical") {
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+ import { Text } from "./text";
3
+
4
+ export interface GroupLeadProps {
5
+ children?: React.ReactNode;
6
+ /**
7
+ * `sm` (the floor) or `md` where the block is a major sub-part. Nothing
8
+ * larger: past `md` the label stops titling a group and starts competing with
9
+ * the section heading above it.
10
+ */
11
+ size?: "sm" | "md";
12
+ numberOfLines?: number;
13
+ testID?: string;
14
+ }
15
+
16
+ /**
17
+ * The label that titles a BLOCK — a titled sub-part of a section. "Deciding
18
+ * factor" over the fact it names, "Delivery" over an address stack, "Next
19
+ * action" over the paragraph someone has to act on. `sm`–`md` medium, in the
20
+ * page's own ink.
21
+ *
22
+ * NOT an `Eyebrow`: that rung labels a VALUE and is calibrated for dense
23
+ * surfaces where quiet is the point. A block someone must act on has no
24
+ * siblings to be quiet among, so `xs` muted reads as an afterthought — and at
25
+ * 12px stacked diacritics crowd, which makes it worse in Vietnamese than the
26
+ * English it was measured in.
27
+ *
28
+ * There is no `weight` prop. Semibold is the heading rung; a group lead that
29
+ * reaches for it is a heading wearing the wrong name, and the fix is a real
30
+ * `SubsectionHeading` rather than a bolder label.
31
+ */
32
+ export function GroupLead(props: GroupLeadProps) {
33
+ const { children, size = "sm", numberOfLines, testID } = props;
34
+ return (
35
+ <Text size={size} weight="medium" numberOfLines={numberOfLines} testID={testID}>
36
+ {children}
37
+ </Text>
38
+ );
39
+ }
package/src/kpi_card.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
2
2
  import { Text } from "./text";
3
+ import { Eyebrow } from "./eyebrow";
3
4
  import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
4
5
  import { TrendChip } from "./trend_chip";
5
6
  import { InfoPopover } from "./info_popover";
@@ -57,9 +58,9 @@ export function KPICard(props: KPICardProps) {
57
58
  return (
58
59
  <View style={[styles.container, style]}>
59
60
  <View style={styles.labelRow}>
60
- <Text size="xs" color="muted" weight="medium">
61
+ <Eyebrow>
61
62
  {label}
62
- </Text>
63
+ </Eyebrow>
63
64
  {info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
64
65
  </View>
65
66
  <View style={styles.valueRow}>
@@ -1,5 +1,6 @@
1
1
  import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
+ import { Eyebrow } from "./eyebrow";
3
4
  import { colors } from "./colors";
4
5
  import { useLocaleTag } from "./locale";
5
6
 
@@ -112,9 +113,9 @@ export function ProgressBar(props: ProgressBarProps) {
112
113
  {title || caption ? (
113
114
  <View style={styles.header}>
114
115
  {title ? (
115
- <Text size="xs" color="muted" weight="medium">
116
+ <Eyebrow>
116
117
  {title}
117
- </Text>
118
+ </Eyebrow>
118
119
  ) : null}
119
120
  <View style={styles.spacer} />
120
121
  {caption ? (
@@ -1,6 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
+ import { Eyebrow } from "./eyebrow";
4
5
 
5
6
  export interface RecordSummaryMetric {
6
7
  /** Caption above the number ("Outstanding", "Total value"). */
@@ -75,9 +76,9 @@ export function RecordSummary(props: RecordSummaryProps) {
75
76
  </View>
76
77
  {metric ? (
77
78
  <View style={styles.metric}>
78
- <Text size="xs" color="muted" weight="medium" align="right">
79
+ <Eyebrow align="right">
79
80
  {metric.label}
80
- </Text>
81
+ </Eyebrow>
81
82
  {typeof metric.value === "string" ? (
82
83
  <Text size="lg" weight="semibold" tabular align="right" color={metric.tone === "default" ? undefined : metric.tone}>
83
84
  {metric.value}
package/src/sources.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StyleSheet, View } from "react-native";
2
2
  import { colors, solid, tint, type ColorName } from "./colors";
3
3
  import { Text } from "./text";
4
+ import { Eyebrow } from "./eyebrow";
4
5
  import { Icon, type IconName } from "./icon";
5
6
  import { PressableHighlight } from "./pressable_highlight";
6
7
  import { useLoticsLocale } from "./locale";
@@ -49,9 +50,9 @@ export function Sources(props: SourcesProps) {
49
50
  return (
50
51
  <View style={{ gap: 8 }}>
51
52
  {props.label === null ? null : (
52
- <Text size="xs" color="muted" weight="medium">
53
+ <Eyebrow>
53
54
  {props.label ?? locale.sources.heading}
54
- </Text>
55
+ </Eyebrow>
55
56
  )}
56
57
  <View style={styles.row}>
57
58
  {props.sources.map((s) =>
@@ -1,6 +1,7 @@
1
1
  import { View, StyleSheet } from "react-native";
2
2
  import { colors } from "./colors";
3
3
  import { Text } from "./text";
4
+ import { Eyebrow } from "./eyebrow";
4
5
 
5
6
  interface Segment {
6
7
  /** Identifier for the segment — used as a React key. */
@@ -61,9 +62,9 @@ export function StackedProgressBar(props: StackedProgressBarProps) {
61
62
  <View style={styles.container}>
62
63
  <View style={styles.header}>
63
64
  {title ? (
64
- <Text size="xs" color="muted" weight="medium">
65
+ <Eyebrow>
65
66
  {title}
66
- </Text>
67
+ </Eyebrow>
67
68
  ) : null}
68
69
  <View style={styles.spacer} />
69
70
  {caption ? (
@@ -1,6 +1,7 @@
1
1
  import { View, type ViewStyle } from "react-native";
2
2
  import { colors } from "./colors";
3
3
  import { Text } from "./text";
4
+ import { Eyebrow } from "./eyebrow";
4
5
  import { useTooltip } from "./tooltip";
5
6
 
6
7
  export interface StepProgressProps {
@@ -97,9 +98,9 @@ export function StepProgress(props: StepProgressProps) {
97
98
  return (
98
99
  <View style={{ gap: 6, flex: 1 }}>
99
100
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
100
- <Text size="xs" color="muted" weight="medium">
101
+ <Eyebrow>
101
102
  {title}
102
- </Text>
103
+ </Eyebrow>
103
104
  <View style={{ flex: 1 }} />
104
105
  {caption ? (
105
106
  <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
@@ -9,7 +9,7 @@ import { colors } from "./colors";
9
9
  import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
10
10
  import { useFocusRing, composeHandler } from "./use_focus_ring";
11
11
  import { useHover } from "./use_hover";
12
- import { Ref, useCallback, useState } from "react";
12
+ import { Ref, useCallback, useEffect, useState } from "react";
13
13
  import { Icon, IconName } from "./icon";
14
14
  import { IconButton } from "./icon_button";
15
15
  import { ShortcutBadge } from "./shortcut_badge";
@@ -111,13 +111,27 @@ export function TextInputField(props: TextInputFieldProps) {
111
111
  minLines: numberOfLines ?? 1,
112
112
  });
113
113
 
114
+ // A VALUE THAT ARRIVES FROM OUTSIDE HAS TO BE MEASURED TOO. `measure` runs on
115
+ // `onChangeText`, which only fires for what the USER types — so a field whose
116
+ // value is replaced by a refetch, a workflow, an agent or a form reset kept
117
+ // the height it was measured at, and any extra lines fell outside the box.
118
+ // Typing one character into it snapped it to the right height, which is the
119
+ // tell: the measurement was never wrong, it was never taken.
120
+ useEffect(() => {
121
+ if (autoGrow) autoGrowResult.measure();
122
+ }, [value, autoGrow, autoGrowResult.measure]);
123
+
114
124
  // `containerHeight` is the CONTENT's height, and the box is `border-box`, so
115
125
  // growing to fit it means adding the padding AND the border. Adding only the
116
- // padding left an auto-grown field 2px short of its own text with
117
- // `overflow: hidden` on the non-scrolling path, the last line was silently
118
- // clipped by exactly the border. The same trap `CONTROL_TEXT_INSET` documents
119
- // on the horizontal axis: read the padding, forget the border, land 1px per
120
- // side wrong.
126
+ // padding leaves an auto-grown field 2px short of its own text. The same trap
127
+ // `CONTROL_TEXT_INSET` documents on the horizontal axis: read the padding,
128
+ // forget the border, land 1px per side wrong.
129
+ //
130
+ // The box does NOT hide its overflow. It used to on the non-scrolling path,
131
+ // which meant every way the height could come out wrong ate text with no
132
+ // scrollbar to say so — the failure was invisible in exactly the case that
133
+ // matters, a long value. A correct height overflows by nothing, so hiding it
134
+ // buys nothing and can only conceal the next measurement bug.
121
135
  const height = autoGrow
122
136
  ? Math.max(minHeight ?? 40, autoGrowResult.containerHeight + INPUT_PADDING_Y * 2 + INPUT_BORDER_WIDTH * 2)
123
137
  : minHeight;
@@ -184,7 +198,6 @@ export function TextInputField(props: TextInputFieldProps) {
184
198
  CONTROL_TRANSITION,
185
199
  getInputTextStyle(),
186
200
  height ? { height } : undefined,
187
- autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
188
201
  !editable && styles.disabled,
189
202
  icon && styles.withIcon,
190
203
  showClear && styles.withClear,
@@ -45,6 +45,7 @@ export function useAutoGrowHeight(options: UseAutoGrowHeightOptions = {}): UseAu
45
45
  const maxHeight = maxLines ? maxLines * lineHeight : undefined;
46
46
  const [containerHeight, setContainerHeight] = useState(minHeight);
47
47
  const elementRef = useRef<HTMLTextAreaElement | null>(null);
48
+ const observerRef = useRef<ResizeObserver | null>(null);
48
49
 
49
50
  const clamp = useCallback(
50
51
  (height: number) => {
@@ -58,10 +59,27 @@ export function useAutoGrowHeight(options: UseAutoGrowHeightOptions = {}): UseAu
58
59
  (node: TextInput | null) => {
59
60
  if (Platform.OS !== "web") return;
60
61
  const el = node as unknown as HTMLTextAreaElement | null;
62
+ observerRef.current?.disconnect();
63
+ observerRef.current = null;
61
64
  elementRef.current = el;
62
65
  if (!el) return;
63
66
  const measured = measureScrollHeight(el);
64
67
  if (measured !== null) setContainerHeight(clamp(measured));
68
+
69
+ // HOW MANY LINES THE VALUE TAKES IS A FUNCTION OF THE WIDTH, so the height
70
+ // measured at mount is only right until the box changes width — a drawer
71
+ // opening, a pane resizing, a column re-flowing, the window itself. Mount
72
+ // is also the WORST moment to measure: a field inside a flex row is often
73
+ // laid out wider than its final width for a frame, which measures fewer
74
+ // lines than the value really takes and leaves the box short of its own
75
+ // text. Without this the first correct width never reaches the hook.
76
+ if (typeof ResizeObserver === "undefined") return;
77
+ const observer = new ResizeObserver(() => {
78
+ const next = measureScrollHeight(el);
79
+ if (next !== null) setContainerHeight(clamp(next));
80
+ });
81
+ observer.observe(el);
82
+ observerRef.current = observer;
65
83
  },
66
84
  [clamp],
67
85
  );