@lotics/ui 41.1.0 → 41.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "41.1.0",
3
+ "version": "41.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -109,6 +109,7 @@
109
109
  "react-native": "./src/markdown.tsx",
110
110
  "default": "./src/markdown.web.tsx"
111
111
  },
112
+ "./markdown_types": "./src/markdown_types.ts",
112
113
  "./markdown.css": "./src/markdown.css",
113
114
  "./confidence": "./src/confidence.tsx",
114
115
  "./finding": "./src/finding.tsx",
@@ -194,6 +195,7 @@
194
195
  "./counter": "./src/counter.tsx",
195
196
  "./link": "./src/link.tsx",
196
197
  "./reference_field": "./src/reference_field.tsx",
198
+ "./text_disclosure": "./src/text_disclosure.tsx",
197
199
  "./text_link": "./src/text_link.tsx",
198
200
  "./sort_header": "./src/sort_header.tsx",
199
201
  "./skeleton": "./src/skeleton.tsx",
@@ -25,8 +25,37 @@ export interface InlineTextInputProps {
25
25
  * a payment term, an address, a clause. Default 1 (the single-line field).
26
26
  * Above 1 the field is that many lines tall and wraps, in both states. Enter
27
27
  * then inserts a newline; the field commits on blur (or the ✓ in "buttons").
28
+ *
29
+ * With `autoGrow` this becomes the MINIMUM rather than the whole budget.
28
30
  */
29
31
  numberOfLines?: number;
32
+ /**
33
+ * Grow to fit the value instead of clipping it at `numberOfLines`.
34
+ *
35
+ * A fixed budget is right for a value with a KNOWN shape — a two-line address,
36
+ * a payment term — where reserving the space keeps the page still. It is wrong
37
+ * for open prose, and it fails in the worst way: the field renders a box the
38
+ * value does not fit, with no ellipsis, no scrollbar and no scroll, so the
39
+ * reader is given no evidence that anything is missing. Measured on a record
40
+ * note, 285 characters drew 76px of a 116px value — two lines gone, silently.
41
+ *
42
+ * Growing costs nothing this control was protecting: the field is ONE input in
43
+ * both states, so a grown box is the same height resting and editing, and
44
+ * nothing moves on focus. It grows as you type, which is the same promise the
45
+ * value's own wrapping already makes.
46
+ *
47
+ * Reach for it wherever the length is the AUTHOR's choice rather than the
48
+ * field's. Keep the fixed budget in the two cases it is still right:
49
+ *
50
+ * - **A value that can be arbitrarily long.** Growth has no ceiling — measured,
51
+ * 406 characters is already 238px — so a field that may hold a document wants
52
+ * the reserve, and a length that overflows it is the signal that the value
53
+ * belongs somewhere else.
54
+ * - **A GRID cell** (`variant="bare"` in a `DataGrid`). A cell that varies with
55
+ * its data breaks the row rhythm the grid promises — see the register laws in
56
+ * composition.md. Grow on a record surface, reserve in a table.
57
+ */
58
+ autoGrow?: boolean;
30
59
  accessibilityLabel?: string;
31
60
  /** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
32
61
  * quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
@@ -46,8 +75,9 @@ export interface InlineTextInputProps {
46
75
  * implementation detail.
47
76
  */
48
77
  export function InlineTextInput(props: InlineTextInputProps) {
49
- const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
50
- const multiline = (numberOfLines ?? 1) > 1;
78
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
79
+ // Growing implies wrapping: a field that grows on one line has nowhere to go.
80
+ const multiline = (numberOfLines ?? 1) > 1 || autoGrow === true;
51
81
  const edit = useInlineEdit<string>({ value, onSave });
52
82
 
53
83
  const onKeyPress = useCallback(
@@ -128,6 +158,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
128
158
  onKeyPress={onKeyPress}
129
159
  multiline={multiline}
130
160
  numberOfLines={numberOfLines}
161
+ autoGrow={autoGrow}
131
162
  placeholder={placeholder}
132
163
  accessibilityLabel={accessibilityLabel}
133
164
  // `disabled`, NOT `editable`: TextInputField derives editability as
package/src/markdown.css CHANGED
@@ -60,6 +60,49 @@
60
60
  margin: 0;
61
61
  }
62
62
 
63
+ /*
64
+ * EMBEDDED — markdown that is a VALUE on someone else's page, not the page.
65
+ *
66
+ * The scale above is a document's: `h1` is 24/600, which is exactly a
67
+ * `SectionHeadingTitle`. That is right when the markdown IS the surface (a
68
+ * knowledge doc, a terms page) and wrong the moment it is a field on a record,
69
+ * because the author of the text is then not the author of the page — and
70
+ * increasingly is not a person at all. A model asked for a call summary writes
71
+ * `##` headings and liberal `**bold**`, and the page hands them a rung one step
72
+ * under its own section headings: measured on a real interaction log, a machine
73
+ * summary inside a feed row drew 18px/600 headings over 44 medium-weight runs,
74
+ * so the least-trustworthy content on the screen was also the loudest.
75
+ *
76
+ * Embedded, every heading collapses to ONE treatment at the body's own size,
77
+ * one weight step above `strong`. A heading is still legible as a heading —
78
+ * it owns its line and carries the space around it, which is SHAPE rather than
79
+ * scale — and it can no longer outrank the surface it was dropped into.
80
+ */
81
+ .ui-markdown-embedded h1,
82
+ .ui-markdown-embedded h2,
83
+ .ui-markdown-embedded h3,
84
+ .ui-markdown-embedded h4,
85
+ .ui-markdown-embedded h5,
86
+ .ui-markdown-embedded h6 {
87
+ font-family: Inter_600SemiBold, "apple-system", "BlinkMacSystemFont", "Segoe UI",
88
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
89
+ "Helvetica Neue", sans-serif;
90
+ font-size: 14px;
91
+ line-height: 20px;
92
+ font-weight: 600;
93
+ padding-top: 8px;
94
+ padding-bottom: 2px;
95
+ }
96
+
97
+ /* A value never opens with a gap its container did not ask for. */
98
+ .ui-markdown-embedded > :first-child {
99
+ padding-top: 0;
100
+ }
101
+
102
+ .ui-markdown-embedded > :last-child {
103
+ padding-bottom: 0;
104
+ }
105
+
63
106
  /* Block elements */
64
107
 
65
108
  .ui-markdown p {
package/src/markdown.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "./text";
3
+ import type { MarkdownProps } from "./markdown_types";
3
4
 
4
5
  /**
5
6
  * Native fallback. The rich renderer (`markdown.web.tsx`) is react-markdown over
@@ -7,11 +8,16 @@ import { Text } from "./text";
7
8
  * native, so this renders the source as plain text. Rich markdown shows on web
8
9
  * (custom-code apps + the frontend on web), which is where it's used; the web
9
10
  * variant is picked via the conditional `exports` in package.json.
11
+ *
12
+ * `variant` is accepted and ignored: plain text has one scale, so there is no
13
+ * heading to demote. The props type is NOT re-exported — import it from
14
+ * `@lotics/ui/markdown_types`, the same way `MediaPlayerProps` is reached.
10
15
  */
11
- export function Markdown({ children }: { children: string }) {
16
+ export function Markdown({ children }: MarkdownProps) {
12
17
  return (
13
18
  <View>
14
19
  <Text size="sm">{children}</Text>
15
20
  </View>
16
21
  );
17
22
  }
23
+
@@ -5,12 +5,17 @@ import ReactMarkdown from "react-markdown";
5
5
  import { remarkGfmSafe } from "./remark_gfm_safe";
6
6
  import { Icon } from "./icon";
7
7
  import { colors } from "./colors";
8
+ import type { MarkdownProps } from "./markdown_types";
8
9
 
9
- export function Markdown({ children }: { children: string }) {
10
+ export function Markdown({ children, variant = "document" }: MarkdownProps) {
11
+ const embedded = variant === "embedded";
10
12
  return (
11
13
  <View>
12
- <div className="ui-markdown">
13
- <ReactMarkdown remarkPlugins={[remarkGfmSafe]} components={markdownComponents}>
14
+ <div className={embedded ? "ui-markdown ui-markdown-embedded" : "ui-markdown"}>
15
+ <ReactMarkdown
16
+ remarkPlugins={[remarkGfmSafe]}
17
+ components={embedded ? embeddedComponents : markdownComponents}
18
+ >
14
19
  {children}
15
20
  </ReactMarkdown>
16
21
  </div>
@@ -18,10 +23,61 @@ export function Markdown({ children }: { children: string }) {
18
23
  );
19
24
  }
20
25
 
26
+
21
27
  const markdownComponents = {
22
28
  table: CopyableTable,
23
29
  };
24
30
 
31
+ /**
32
+ * EMBEDDED MARKDOWN IS DEMOTED IN THE OUTLINE, not only on the type ladder.
33
+ *
34
+ * Sizing the headings down fixes what a screen SHOWS and nothing about what it
35
+ * ANNOUNCES: an `h2` the author of the text happened to write is still an `h2`,
36
+ * so it lands in heading navigation as a PEER of the page's own sections.
37
+ * Measured on a record surface, the outline read
38
+ *
39
+ * Activity · "Tóm tắt cuộc họp (AI) — …" · "Bài học …" · Details · Next action
40
+ *
41
+ * — a model's call-summary title sitting between two real sections, from inside
42
+ * one row of a feed that can hold twenty more. No visual probe finds this, and
43
+ * fixing the size is what makes it invisible: the defect stops looking wrong at
44
+ * the exact moment it stops being measurable.
45
+ *
46
+ * The page's sections are `h2` (`SectionHeading`), and embedded markdown is a
47
+ * VALUE inside one — inside a row, usually — so it starts at `h4`: deep enough
48
+ * that it can never be a section's peer, shallow enough to keep its own
49
+ * hierarchy. It stays a heading rather than becoming a `<div>`, because a long
50
+ * summary genuinely has parts and a reader navigating INSIDE it should still
51
+ * find them.
52
+ */
53
+ const EMBEDDED_HEADING_LEVEL: Record<string, "h4" | "h5" | "h6"> = {
54
+ h1: "h4",
55
+ h2: "h5",
56
+ h3: "h6",
57
+ h4: "h6",
58
+ h5: "h6",
59
+ h6: "h6",
60
+ };
61
+
62
+ type HeadingProps = React.ComponentPropsWithoutRef<"h4"> & { node?: unknown };
63
+
64
+ function embeddedHeading(from: keyof typeof EMBEDDED_HEADING_LEVEL) {
65
+ const Tag = EMBEDDED_HEADING_LEVEL[from];
66
+ return function EmbeddedHeading({ node: _node, children, ...rest }: HeadingProps) {
67
+ return <Tag {...rest}>{children}</Tag>;
68
+ };
69
+ }
70
+
71
+ const embeddedComponents = {
72
+ ...markdownComponents,
73
+ h1: embeddedHeading("h1"),
74
+ h2: embeddedHeading("h2"),
75
+ h3: embeddedHeading("h3"),
76
+ h4: embeddedHeading("h4"),
77
+ h5: embeddedHeading("h5"),
78
+ h6: embeddedHeading("h6"),
79
+ };
80
+
25
81
  function CopyableTable(props: React.ComponentPropsWithoutRef<"table"> & { node?: unknown }) {
26
82
  const { node: _node, children, ...rest } = props;
27
83
  const tableRef = useRef<HTMLTableElement>(null);
@@ -0,0 +1,21 @@
1
+ export interface MarkdownProps {
2
+ children: string;
3
+ /**
4
+ * Whose type ladder the headings belong to.
5
+ *
6
+ * **`"document"` (default)** — the markdown IS the surface: a knowledge doc, a
7
+ * terms page, a rendered artifact. Its `h1` is the page's `h1`.
8
+ *
9
+ * **`"embedded"`** — the markdown is a VALUE on a page someone else designed:
10
+ * a field on a record, a summary in a feed row, a note in a drawer. Every
11
+ * heading collapses to the body's own size so it cannot outrank the section
12
+ * that contains it.
13
+ *
14
+ * **Reach for `"embedded"` whenever the text was not written by whoever built
15
+ * the screen** — and that is now the common case, because the writer is
16
+ * routinely a model. A model asked for a summary emits `##` headings and heavy
17
+ * `**bold**` with no idea what surrounds them, so document scale puts the
18
+ * least-trustworthy content on the screen at the loudest rung on it.
19
+ */
20
+ variant?: "document" | "embedded";
21
+ }
@@ -36,15 +36,25 @@ interface MemberChipProps {
36
36
  */
37
37
  export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
38
38
  const displayName = name?.trim() || "Unknown";
39
+ // THE TEXT FOLLOWS THE RUNG. `size` used to scale the avatar alone, so a chip
40
+ // asked for the dense rung got a 24px avatar beside a 14px name — half-dense,
41
+ // and the mismatch surfaces wherever the chip sits INSIDE a sentence: a 12px
42
+ // "Logged by" running straight into a 14px name reads as two type systems
43
+ // meeting mid-phrase, and their line boxes (16 against 20) then refuse to
44
+ // share a baseline under any `alignItems: "center"`.
45
+ //
46
+ // Only `sm` steps down, because only `sm` claims to be dense; every larger
47
+ // rung keeps the body size, where a person's name belongs.
48
+ const textSize = size === "sm" ? "xs" : "sm";
39
49
  return (
40
50
  <View style={[styles.row, style]}>
41
51
  <Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
42
52
  <View style={styles.text}>
43
- <Text userSelect="none" numberOfLines={1}>
53
+ <Text userSelect="none" size={textSize} numberOfLines={1}>
44
54
  {displayName}
45
55
  </Text>
46
56
  {secondary ? (
47
- <Text userSelect="none" size="sm" color="zinc-500" numberOfLines={1}>
57
+ <Text userSelect="none" size={textSize} color="zinc-500" numberOfLines={1}>
48
58
  {secondary}
49
59
  </Text>
50
60
  ) : null}
@@ -0,0 +1,70 @@
1
+ import { Pressable } from "react-native";
2
+ import { TextLink } from "./text_link";
3
+ import type { TextSize } from "./text";
4
+
5
+ export interface TextDisclosureProps {
6
+ /** Open state — controlled, because the content it reveals is the caller's. */
7
+ expanded: boolean;
8
+ onToggle: (expanded: boolean) => void;
9
+ /** What is behind it, as a noun: "transcript", "3 earlier replies". The verb
10
+ * is supplied — a caller writing its own "Show …" ends up with two vocabularies
11
+ * for one control the first time somebody writes "View". */
12
+ label: string;
13
+ /** Matches the prose it sits in. Default "sm". */
14
+ size?: TextSize;
15
+ /** Announced name, when `label` alone is ambiguous on a page with several. */
16
+ accessibilityLabel?: string;
17
+ testID?: string;
18
+ }
19
+
20
+ /**
21
+ * An in-prose DISCLOSURE: muted underlined text that reveals adjacent content in
22
+ * place — a transcript under its player, a long value under its summary.
23
+ *
24
+ * The counterpart to {@link TextLink}, and the pair is the point: underlined text
25
+ * either GOES somewhere or REVEALS something, and the INK says which.
26
+ *
27
+ * blue + `role="link"` (`Link`) → leaves this surface
28
+ * muted underline (this) → reveals more of it, right here
29
+ *
30
+ * Both answer "show me more"; only one takes the reader away, which is the single
31
+ * question the affordance exists to answer. Anything that MUTATES — saves, sends,
32
+ * deletes, opens an overlay — carries a control surface instead (`Button` in
33
+ * chrome, `InlineButton` on a field). See composition.md §"Commit & feedback surfaces".
34
+ *
35
+ * **Why this is a component rather than three lines at each call site.** It
36
+ * carries a contract, and every part of it is a thing a hand-roll gets wrong:
37
+ *
38
+ * - **`role="button"`, never `role="link"`.** The obvious reach is `Link`, which
39
+ * announces a destination — so a screen-reader user is told they are leaving a
40
+ * page that is about to unfold under them.
41
+ * - **Muted, never blue.** Blue is the promise of a trip. One caller passing the
42
+ * navigation ink is all it takes for underline to stop meaning anything.
43
+ * - **Reveal only.** The exception to "underline navigates" is this narrow, and a
44
+ * named component is what keeps it narrow — the next author reaching for quiet
45
+ * text to hang a DELETE on finds a name that does not fit.
46
+ *
47
+ * **What it is not.** Not `Accordion`: that is a list-row disclosure with a header
48
+ * and a chevron, right for a run of expandable rows and heavy furniture for a verb
49
+ * inside one. Not `Button color="muted"`: measured, that is transparent,
50
+ * borderless and undecorated at rest, so its affordance arrives only on hover —
51
+ * invisible to keyboard and touch, and against a text column it reads as a label.
52
+ */
53
+ export function TextDisclosure(props: TextDisclosureProps) {
54
+ const { expanded, onToggle, label, size = "sm", accessibilityLabel, testID } = props;
55
+ return (
56
+ <Pressable
57
+ onPress={() => onToggle(!expanded)}
58
+ accessibilityRole="button"
59
+ // The STATE, announced. Without it a screen reader hears the same words
60
+ // whichever way the disclosure is sitting.
61
+ aria-expanded={expanded}
62
+ accessibilityLabel={accessibilityLabel}
63
+ testID={testID}
64
+ >
65
+ <TextLink size={size} color="muted">
66
+ {expanded ? `Hide ${label}` : `Show ${label}`}
67
+ </TextLink>
68
+ </Pressable>
69
+ );
70
+ }
package/src/text_link.tsx CHANGED
@@ -26,9 +26,19 @@ export interface TextLinkProps extends TextProps {
26
26
  * The act mode briefly became its own surface-less component and is now gone
27
27
  * entirely: it made underline mean two things separable only by ink, and it competed
28
28
  * with the `Button` colour ladder for the job `muted` already does. **Underlined text
29
- * is the NAVIGATION affordance; anything that acts carries a control surface** —
30
- * `Button` in chrome, `InlineButton` on a field. A quiet verb that cannot sit against
31
- * a text column belongs in chrome, not in a rung of its own.
29
+ * GOES somewhere or REVEALS something; anything that MUTATES carries a control
30
+ * surface** — `Button` in chrome, `InlineButton` on a field.
31
+ *
32
+ * The reveal half is the one narrow exception, and this component is how you build
33
+ * it: an in-prose DISCLOSURE (a transcript under its player, a long value under its
34
+ * summary) wrapped in your own `Pressable`, `color="muted"` so it never wears the
35
+ * navigation blue. It exists because the rule used to end "a quiet verb that cannot
36
+ * sit against a text column belongs in chrome" — and a disclosure cannot go to
37
+ * chrome, it belongs to the thing above it. The two controls that fit chrome both
38
+ * fail here: a `muted` Button is transparent and undecorated at rest (a hover-only
39
+ * affordance), and an `Accordion` is a list row nested inside a list row. The INK is
40
+ * what keeps underline unambiguous — blue leaves, muted stays. See composition.md
41
+ * §"Commit & feedback surfaces".
32
42
  */
33
43
  export function TextLink(props: TextLinkProps) {
34
44
  const { icon, href, children, color, style, ...textProps } = props;