@lotics/ui 44.5.0 → 44.7.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
@@ -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
 
package/docs/reviewing.md CHANGED
@@ -236,6 +236,13 @@ A resting register is the cheapest thing to screenshot and the least likely to h
236
236
  (a feed, a thread) above a bounded one buries it, and the burial deepens with use.
237
237
  - **Press every control** and look at the state you land in. Commonest miss: a value rendering as
238
238
  a coloured chip in a register and as bare grey text in its own EDITOR.
239
+ - **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
240
+ finds anything with an `onPress`; a keyboard finds only what carries a role and a tab stop, and
241
+ the two lists come apart silently — the screen looks identical either way, and the author
242
+ testing with a mouse never learns. The reliable producer is styled text: underline, a hover
243
+ wash, a "verb" colour applied to a plain container, which reads as a control to the eye while
244
+ announcing as prose. Anything the eye calls a control and the tab order omits is unreachable,
245
+ not merely awkward. Count both lists; a difference is the finding.
239
246
  - **Diff a promoted field against where it came from.** Promoting means MOVING; a copy left behind
240
247
  gives one field two editors.
241
248
  - **Read every string in its SETTLED state**, not the streaming one.
@@ -473,6 +480,36 @@ receives `mouseenter`, because the pointer is already stationary over it — so
473
480
  dies at the moment of the swap and reads as a flash. Keep one node across the swap rather than
474
481
  restoring the state by hand.
475
482
 
483
+ ### Reachability — what the eye calls a control against what the tab order holds
484
+
485
+ Two lists off one render. The first is what a keyboard can operate; the second is everything that
486
+ LOOKS operable — styled text is the producer, so underline is the cheapest thing to sweep for.
487
+
488
+ ```js
489
+ () => {
490
+ const root = document.querySelector('[role="dialog"]') ?? document.body;
491
+ const operable = [...root.querySelectorAll(
492
+ 'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), input, select, textarea',
493
+ )];
494
+ const looksPressable = [...root.querySelectorAll("*")].filter((e) => {
495
+ if (e.children.length || !(e.textContent || "").trim()) return false;
496
+ const cs = getComputedStyle(e);
497
+ return cs.textDecorationLine.includes("underline") || cs.cursor === "pointer";
498
+ });
499
+ const unreachable = looksPressable.filter((e) => !e.closest(
500
+ 'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])',
501
+ ));
502
+ return {
503
+ operable: operable.length,
504
+ unreachable: unreachable.map((e) => (e.textContent || "").trim().slice(0, 40)),
505
+ };
506
+ }
507
+ ```
508
+
509
+ `unreachable` must come back empty. A name in it is a control only a pointer can reach — and the
510
+ count beside it is the other half of the reading: a whole pane offering two or three tab stops is
511
+ usually not a minimal screen, it is a screen whose verbs are all prose.
512
+
476
513
  Driving the kit's own anatomies — a `PressDoor` row, a portalled overlay, a custom pointer drag —
477
514
  has three gotchas of its own: [testing.md](./testing.md).
478
515
 
@@ -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.5.0",
3
+ "version": "44.7.0",
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}>
package/src/locale.tsx CHANGED
@@ -83,6 +83,12 @@ export interface LoticsLocale {
83
83
  * a phase's rows. The kit owns the wording so one app does not say "Show" while
84
84
  * the next says "Expand" for the same gesture. */
85
85
  checklist: { expand: string; collapse: string };
86
+ /** `TextDisclosure` supplies the VERB so one app cannot say "Show" where the
87
+ * next says "View" for the same gesture — the same reason `checklist` above
88
+ * owns its pair. It takes the revealed thing as a noun and builds the phrase,
89
+ * because word order is not shared: English puts the verb first, and a
90
+ * language that does not would otherwise be stuck with a prefix. */
91
+ textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
86
92
  sectionHeading: { info: string };
87
93
  /** `ErrorState`'s retry button. The kit owns the wording so "try again" is
88
94
  * phrased identically everywhere instead of hand-written per app. */
@@ -288,6 +294,7 @@ export const en: LoticsLocale = {
288
294
  ledger: { rowDetails: (label) => `${label} details` },
289
295
  stepper: { complete: "Complete step", progress: "Progress" },
290
296
  checklist: { expand: "Show", collapse: "Hide" },
297
+ textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
291
298
  sectionHeading: { info: "About this data" },
292
299
  errorState: { retry: "Try again" },
293
300
  chip: { remove: "Remove" },
@@ -466,6 +473,7 @@ export const vi: LoticsLocale = {
466
473
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
467
474
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
468
475
  checklist: { expand: "Mở", collapse: "Thu gọn" },
476
+ textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
469
477
  sectionHeading: { info: "Giải thích dữ liệu" },
470
478
  errorState: { retry: "Thử lại" },
471
479
  chip: { remove: "Xóa" },
@@ -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>
@@ -1,5 +1,6 @@
1
1
  import { Pressable } from "react-native";
2
2
  import { TextLink } from "./text_link";
3
+ import { useLoticsLocale } from "./locale";
3
4
  import type { TextSize } from "./text";
4
5
 
5
6
  export interface TextDisclosureProps {
@@ -52,6 +53,7 @@ export interface TextDisclosureProps {
52
53
  */
53
54
  export function TextDisclosure(props: TextDisclosureProps) {
54
55
  const { expanded, onToggle, label, size = "sm", accessibilityLabel, testID } = props;
56
+ const locale = useLoticsLocale();
55
57
  return (
56
58
  <Pressable
57
59
  onPress={() => onToggle(!expanded)}
@@ -63,7 +65,7 @@ export function TextDisclosure(props: TextDisclosureProps) {
63
65
  testID={testID}
64
66
  >
65
67
  <TextLink size={size} color="muted">
66
- {expanded ? `Hide ${label}` : `Show ${label}`}
68
+ {expanded ? locale.textDisclosure.hide(label) : locale.textDisclosure.show(label)}
67
69
  </TextLink>
68
70
  </Pressable>
69
71
  );
@@ -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
  );