@lotics/ui 27.6.0 → 27.8.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/docs/catalog.md CHANGED
@@ -340,7 +340,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
340
340
  - **`text_utils`** — text/typography plumbing: `getTextColor` (the TextColor→hex map incl.
341
341
  the AA-cleared valence set), the Inter `fontFamily*` stacks, and `getInputTextStyle` /
342
342
  `getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS
343
- auto-zoom; only for hand-rolled raw inputs.
343
+ auto-zoom; only for hand-rolled raw inputs. It carries the **ink** as well as the size,
344
+ because omitting `color` on a native `<input>` does not inherit — it falls to the UA
345
+ default, pure black, against the `zinc-900` every resting value beside it uses. A field
346
+ whose text darkens the instant it is focused reads as the text MOVING (at 14px a darkness
347
+ change re-weights every stroke's antialiasing), which is a bug no box measurement can
348
+ see. Hand-rolling a raw input means taking this style, not just its font size.
344
349
 
345
350
  ### Theming, locale & tokens
346
351
 
@@ -715,7 +720,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
715
720
  EVERY `Inline*` editor takes it. `trailing` is decoration only (a chevron, a spinner): it
716
721
  renders INSIDE the press target, so a button there would be a button in a button.
717
722
  With `actions` the field renders through ONE shell that owns the surface in BOTH modes — so
718
- the verbs never move when the editor swaps to its input, the control inside goes `seamless`
723
+ the verbs never move when the editor opens, the control inside goes `seamless`
719
724
  (drawing no second box), hover is tracked on the box (react-native-web hands a parent's hover
720
725
  to the innermost pressable, so a control-tracked border drops out as the pointer crosses a
721
726
  verb), and the focus ring paints on the whole FIELD via focus-within, not on the value region.
@@ -877,7 +882,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
877
882
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
878
883
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
879
884
  like the editors above and below it), whose press opens a PEEK of that record's facts.
880
- `name` + optional `code` + `facts`, plus the verb callbacks below. The verbs live INSIDE the
885
+ `name` + optional `code` + `facts`, plus the verb callbacks below. The peek takes THE FIELD'S
886
+ WIDTH (`inheritTriggerWidth`, as `InlineSelect` does), floored at `MIN_CONTROL_WIDTH` — a
887
+ panel narrower than the control that opened it reads as belonging to something else. So the
888
+ field truncates its name to one line and the peek's header WRAPS the whole of it: the column
889
+ stays scannable, the full value is one press away. Between the floor and `labelWidth +
890
+ MIN_CONTROL_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width.
891
+ The verbs live INSIDE the
881
892
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
882
893
  dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
883
894
  four acts that are four acts. The row splits by WHAT EACH TOUCHES, and the spacer is that
@@ -46,8 +46,20 @@ narrow containers. A right-floated button aligns to nothing; only OVERLAY footer
46
46
  When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
47
47
  form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
48
48
  chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
49
- layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
50
- point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
49
+ layout), click OR keyboard focus opens it for editing **with nothing moving** (zero reflow, the
50
+ whole point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"`
51
+ (✓ / ✕).
52
+
53
+ **How "nothing moves" is achieved differs by editor, and the difference is a rule.** A field whose
54
+ resting value is the RAW string (`InlineTextInput`) is ONE `<input>` in both states — it never
55
+ swaps elements, and `editing` is a style state driven by focus. Two elements cannot be made to
56
+ draw the same string identically: matching the padding, the border and the ink still leaves an
57
+ `<input>` centring by FONT METRICS where a `<div>` positions by LINE BOX, a quarter-CSS-pixel
58
+ residual that no property closes, is invisible to `getBoundingClientRect` (it reports the rounded
59
+ box), and lands on a real device pixel at fractional scaling. Where the resting display is
60
+ FORMATTED and the editor shows something else (`1,250 kg` → `1250`, `31/07/2026` → a segmented
61
+ field), the string changes anyway, so those editors do swap and should. **The rule for a new
62
+ editor: same string in both states ⇒ one element; different string ⇒ swap.**
51
63
 
52
64
  **Commit-on-blur would race the press that caused it — the kit closes that race for you.**
53
65
  Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
@@ -77,15 +89,16 @@ dialog save, a debounced autosave).
77
89
  ### Keyboard entry — type → Tab → type
78
90
 
79
91
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
80
- input-swap editor — `InlineTextInput`, `InlineNumberInput`, `InlineTimePicker`,
81
- `InlineDatePicker` — opens edit mode immediately with the input focused; commit-on-blur then
82
- makes Tab itself the commit, so the chain is type → Tab → type with the next editor already
83
- open. Pointer focus never auto-opens (mousedown records "pointer" modality before focus fires
84
- `interaction_modality.ts`), so the click path is exactly what it always was. When a
85
- **keyboard** close (Enter/Escape) leaves focus on `<body>`, the editor returns focus to its
86
- resting view so the next Tab continues from the field. A **pointer** close (click ✓/✕, or
87
- click away onto non-focusable space) does NOT restore a mouse user has no next Tab to
88
- preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
92
+ SWAP editor — `InlineNumberInput`, `InlineTimePicker`, `InlineDatePicker` — opens edit mode
93
+ immediately with the input focused; commit-on-blur then makes Tab itself the commit, so the chain
94
+ is type → Tab → type with the next editor already open. Pointer focus never auto-opens (mousedown
95
+ records "pointer" modality before focus fires — `interaction_modality.ts`), so the click path is
96
+ exactly what it always was. `InlineTextInput` needs none of this its input is always mounted, so
97
+ focus IS the edit by either route. When a **keyboard** close (Enter/Escape) leaves focus on
98
+ `<body>`, a swap editor returns focus to its resting view so the next Tab continues from the
99
+ field (the always-mounted one never loses focus, so there is nothing to restore). A **pointer**
100
+ close (click ✓/✕, or click away onto non-focusable space) does NOT restore a mouse user has no
101
+ next Tab to preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
89
102
  restore is gated on modality via `shouldRestoreFocusOnClose`).
90
103
 
91
104
  **Typed dates.** `InlineDatePicker`'s keyboard mode is an internal segmented date field
@@ -131,10 +144,14 @@ One per type:
131
144
  a multi `OptionList` (checkbox rows), CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
132
145
  field.
133
146
 
134
- They split across two shared shells. The same-height **input-swap** editors (`InlineTextInput`,
147
+ They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
135
148
  `InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
136
- **`useInlineEdit`** hook (the view⇄edit toggle, draft buffer, and async save) — swap a custom
137
- input into that pair to join the family. The **popover** editors (`InlineSelect`,
149
+ **`useInlineEdit`** hook (the draft buffer, async save, and for the two that swap the view⇄edit
150
+ toggle) — pass a custom input to that pair to join the family. On the frame, **`editing` means an
151
+ input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
152
+ host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
153
+ which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
154
+ field they would be both a false claim and dead buttons. The **popover** editors (`InlineSelect`,
138
155
  `InlineMemberSelect`, `InlineDatePicker`) instead compose **`InlineEditView`**
139
156
  (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
140
157
  error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
@@ -136,7 +136,11 @@ interface Customer {
136
136
  // inline Tax ID fix-up in the Customer section.
137
137
  const KNOWN_CUSTOMERS: Customer[] = [
138
138
  { id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden", since: "2019-03-14" },
139
- { id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
139
+ // DELIBERATELY too long for one line. A register of tidy two-word names cannot
140
+ // show what a reference does when it does not fit — which is truncate in the
141
+ // field and wrap in the peek — so the one fixture the template opens with is the
142
+ // realistic worst case. Real customer names look like this.
143
+ { id: "cus_02", name: "Harbor Freight Lines & Coastal Forwarding Group", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
140
144
  { id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803", since: "2023-06-19" },
141
145
  { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany", since: "2018-01-30" },
142
146
  { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium", since: "2022-09-08" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.6.0",
3
+ "version": "27.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -34,7 +34,7 @@ export function useInlineEdit<T>(opts: {
34
34
  const { value, onSave, equals } = opts;
35
35
  const labels = useLoticsLocale().inline;
36
36
  const [editing, setEditing] = useState(false);
37
- const [draft, setDraft] = useState<T>(value);
37
+ const [draft, setDraftState] = useState<T>(value);
38
38
  const [saving, setSaving] = useState(false);
39
39
  const [error, setError] = useState<string | null>(null);
40
40
  // Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
@@ -42,8 +42,23 @@ export function useInlineEdit<T>(opts: {
42
42
  // input — is then a no-op, so the field commits/cancels exactly once.
43
43
  const active = useRef(false);
44
44
 
45
+ // A keystroke IS an edit, so writing the draft re-arms the field. `editing` and
46
+ // `draft` must never disagree: with `editing` false the field renders the STORED
47
+ // value, so the keystroke is swallowed AND the trailing blur no-ops — the user
48
+ // types into a dead field and the text is gone. That state was unreachable while
49
+ // an editor mounted on demand (Escape unmounted the input, so there was nothing
50
+ // left to type into); it exists the moment one stays mounted, which
51
+ // `InlineTextInput` now does — Escape reverts and keeps the caret. Re-arming
52
+ // cannot double-commit: the exactly-once guard exists to swallow the blur that
53
+ // TRAILS an exit, and a keystroke after that exit is a new edit that must save.
54
+ const setDraft = useCallback((next: T) => {
55
+ active.current = true;
56
+ setEditing(true);
57
+ setDraftState(next);
58
+ }, []);
59
+
45
60
  const begin = useCallback(() => {
46
- setDraft(value);
61
+ setDraftState(value);
47
62
  setError(null);
48
63
  active.current = true;
49
64
  setEditing(true);
@@ -139,7 +154,19 @@ export function useInlineEditFocusRestore(editing: boolean, restore: () => void)
139
154
  export type InlineEditVariant = "framed" | "bare";
140
155
 
141
156
  interface InlineEditFrameProps {
157
+ /** An input is MOUNTED inside the frame — which element to render, and whether
158
+ * the surface drops its resting padding to host one. For an editor that swaps
159
+ * this is the same thing as "an edit is open", which is why it was one flag. */
142
160
  editing: boolean;
161
+ /**
162
+ * Whether an edit is actually OPEN, for an editor where that differs from
163
+ * `editing` — `InlineTextInput` mounts its input at REST, so it passes
164
+ * `editing` constant. Only the ✓/✕ verbs read this: they say "there is an edit
165
+ * to accept or reject", so on a resting field they are a false claim AND dead
166
+ * (`commit`/`cancel` both no-op with nothing active). Defaults to `editing`,
167
+ * which is the truth for every editor whose input arrives with the edit.
168
+ */
169
+ editOpen?: boolean;
143
170
  /** The formatted current value, shown in view mode. Empty → placeholder. */
144
171
  display: string;
145
172
  placeholder?: string;
@@ -467,6 +494,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
467
494
  const labels = useLoticsLocale().inline;
468
495
  const {
469
496
  editing,
497
+ editOpen,
470
498
  display,
471
499
  placeholder,
472
500
  onBegin,
@@ -582,7 +610,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
582
610
  ) : null}
583
611
  </View>
584
612
  )}
585
- {controls === "buttons" ? (
613
+ {controls === "buttons" && (editOpen ?? editing) ? (
586
614
  <View style={styles.buttons}>
587
615
  <IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
588
616
  <IconButton icon="x" color="secondary" size="sm" tooltip={labels.cancel} onPress={onCancel} disabled={saving} />
@@ -1,5 +1,6 @@
1
1
  import { useCallback, type ReactNode } from "react";
2
2
  import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
3
+ import { colors } from "./colors";
3
4
  import { TextInputField } from "./text_input_field";
4
5
  import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
6
 
@@ -13,15 +14,16 @@ export interface InlineTextInputProps {
13
14
  * an explicit ✓ saves and ✕ reverts. */
14
15
  controls?: InlineEditControls;
15
16
  disabled?: boolean;
16
- /** Strike + mute the resting value (a completed item that stays editable). */
17
+ /** Strike + mute the value (a completed item that stays editable). It stays
18
+ * struck while being edited — the field is one element, so a treatment that
19
+ * dropped on focus would be exactly the jump this control exists to avoid. */
17
20
  struck?: boolean;
18
21
  /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
19
22
  variant?: InlineEditVariant;
20
23
  /**
21
24
  * Line budget for a value a reader has to read WHOLE rather than recognise —
22
25
  * a payment term, an address, a clause. Default 1 (the single-line field).
23
- * Above 1 the resting value wraps to that many lines AND the editor opens
24
- * multiline at the same height, so the row does not reflow on click. Enter
26
+ * Above 1 the field is that many lines tall and wraps, in both states. Enter
25
27
  * then inserts a newline; the field commits on blur (or the ✓ in "buttons").
26
28
  */
27
29
  numberOfLines?: number;
@@ -34,10 +36,14 @@ export interface InlineTextInputProps {
34
36
  }
35
37
 
36
38
  /**
37
- * An inline-editable single-line text value: reads as plain text, reveals an
38
- * edit affordance on hover, and swaps in-place to a text input on click at the
39
- * same height, so the form never reflows. The preferred control for editing a
39
+ * An inline-editable single-line text value: reads as plain text, darkens on
40
+ * hover, and opens for editing on focus. The preferred control for editing a
40
41
  * value in a dense record / detail surface.
42
+ *
43
+ * It is ONE `<input>` in both states — never a resting view swapped for an
44
+ * editor — so nothing about the value can move when it is focused. See the
45
+ * comment on `editing` below for why that is load-bearing rather than an
46
+ * implementation detail.
41
47
  */
42
48
  export function InlineTextInput(props: InlineTextInputProps) {
43
49
  const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
@@ -64,7 +70,33 @@ export function InlineTextInput(props: InlineTextInputProps) {
64
70
 
65
71
  return (
66
72
  <InlineEditFrame
67
- editing={edit.editing}
73
+ // ALWAYS the input — this editor never swaps elements, and `editing` is a
74
+ // STYLE state (focus) rather than a mount.
75
+ //
76
+ // The swap was the bug. A resting `Text` and an `<input>` must render the
77
+ // same string identically, and nothing enforces it: the padding drifted
78
+ // (8px), then the transparent border (1px), then the ink (zinc-900 vs the
79
+ // UA's black) — each fixed in turn, each revealing the next. The last one
80
+ // cannot be fixed at all: measured on an interior 4x crop with no border or
81
+ // ring in frame, the glyph mass sits 1 DEVICE pixel lower while editing
82
+ // (row centroid 12.985 -> 13.985, identical ink, identical column
83
+ // centroid). Both paths compute a 10px text top, so it is not a padding
84
+ // mistake — an `<input>` centres its text by FONT METRICS and a `<div>`
85
+ // positions it by LINE BOX, and the residual is a quarter of a CSS pixel.
86
+ // No amount of matching properties closes it; only rendering one element
87
+ // does. Hence: one element.
88
+ //
89
+ // Scope is this editor alone. Number, date and time keep the swap, because
90
+ // their resting display is FORMATTED (`1,250 kg`, `31/07/2026`) and their
91
+ // editor shows something else entirely — the string changes, so a quarter
92
+ // pixel is not perceptible and an always-input would buy nothing while
93
+ // costing reformat-on-blur inside a field the user is typing in.
94
+ editing
95
+ // ...but the ✓/✕ verbs still key off a REAL edit. `editing` above is a
96
+ // mount decision now, and the verbs are not: at rest there is nothing to
97
+ // accept and nothing to revert, so rendering them would both claim
98
+ // otherwise and hand the user two buttons that no-op.
99
+ editOpen={edit.editing}
68
100
  display={value}
69
101
  placeholder={placeholder}
70
102
  onBegin={edit.begin}
@@ -81,15 +113,44 @@ export function InlineTextInput(props: InlineTextInputProps) {
81
113
  actions={actions}
82
114
  >
83
115
  <TextInputField
84
- value={edit.draft}
116
+ // The DRAFT only exists once editing has begun; before that the field
117
+ // must show the record's value, or an external update would be masked by
118
+ // a stale draft from the last edit (`begin` is what syncs them).
119
+ value={edit.editing ? edit.draft : value}
85
120
  onChangeText={edit.setDraft}
121
+ // Focus IS begin. There is no press-to-open any more: the element the
122
+ // user clicks is already the editor, so the gesture that used to mount it
123
+ // now just opens a draft on it. This is also why `autoFocus` is GONE —
124
+ // mounted permanently, it would have every text field on a record fight
125
+ // for focus on load.
126
+ onFocus={edit.begin}
86
127
  onBlur={onBlur}
87
128
  onKeyPress={onKeyPress}
88
- autoFocus
89
129
  multiline={multiline}
90
130
  numberOfLines={numberOfLines}
91
131
  placeholder={placeholder}
92
132
  accessibilityLabel={accessibilityLabel}
133
+ // `disabled`, NOT `editable`: TextInputField derives editability as
134
+ // `!disabled || editable`, so passing `editable` alone can never turn it
135
+ // OFF. With the resting button gone, this is the only thing standing
136
+ // between a read-only field and a typable one.
137
+ disabled={disabled}
138
+ // Everything the resting VIEW used to draw, the input now draws, because
139
+ // there is no resting view left to draw it. Both of these were its job:
140
+ //
141
+ // `bare` — the transparent resting edge. Hover and the focus ring are
142
+ // applied after `style` inside `TextInputField`, so it still darkens on
143
+ // hover and still rings on focus, from the element that now owns it.
144
+ //
145
+ // `struck` — a completed item's value, line-through and muted. This was
146
+ // applied in `fieldContent`, which only ever runs in the resting view, so
147
+ // moving to one element silently dropped it: a done task on the board kept
148
+ // its title upright. Anything else the view used to render has to move the
149
+ // same way, or it goes the same way — quietly.
150
+ style={[
151
+ variant === "bare" ? { borderColor: "transparent", backgroundColor: "transparent" } : null,
152
+ struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null,
153
+ ]}
93
154
  // With verbs on the field, the FRAME owns the surface and the ring.
94
155
  seamless={actions != null}
95
156
  />
@@ -220,6 +220,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
220
220
  triggerRef={anchor}
221
221
  side="bottom"
222
222
  align="start"
223
+ inheritTriggerWidth
223
224
  >
224
225
  {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
225
226
  wearing copies of the field's border, radius, height and hover — and it
@@ -261,12 +262,24 @@ export function ReferenceField(props: ReferenceFieldProps) {
261
262
  rather than a hand-picked font weight; the facts are `DetailRow`s, which
262
263
  is what label-beside-value IS everywhere else on this page; and the
263
264
  verbs sit in the popover's own pinned footer rather than floating after
264
- the last fact. Width matches `Peek`'s own content width so every peek in
265
- an app is the same object.
266
- `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
267
- STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
268
- page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
269
- the summary side-by-side, which is the whole point of a glance. */}
265
+ the last fact.
266
+
267
+ WIDTH COMES FROM THE FIELD (`inheritTriggerWidth`), not a constant. A
268
+ fixed 320 was "every peek in an app is the same object", and the object
269
+ it was actually the same as was nothing on screen: measured on a record
270
+ page, a 558px field opened a 320px panel left-aligned under it, stopping
271
+ 238px short of the edge the reader's eye had just travelled to. A
272
+ popover narrower than the control that opened it reads as belonging to
273
+ something else. `InlineSelect` — the same shape, an inline field opening
274
+ a `triggerRef` popover — already inherits its field's width, so the two
275
+ were following different rules for one job.
276
+
277
+ The prop is floored at `MIN_CONTROL_WIDTH`, so a reference in a dense
278
+ grid column still opens something readable rather than a 90px sliver.
279
+ Between that floor and `labelWidth + MIN_CONTROL_WIDTH + 24` a
280
+ `DetailTable` STACKS its columns, which is the right answer at that
281
+ width, not a failure — 88 keeps the side-by-side grammar wherever there
282
+ is room for it, which is the whole point of a glance. */}
270
283
  {/* `PopoverContent` PARTITIONS its children: a `PopoverHeader` sits above
271
284
  the scroller, a `PopoverFooter` is pinned below it, and everything else
272
285
  scrolls between them. This component used none of that — it disabled
@@ -274,11 +287,12 @@ export function ReferenceField(props: ReferenceFieldProps) {
274
287
  is invisible at three facts and fatal at eleven: the panel ran past the
275
288
  viewport with no way to scroll, carrying its own Save button off-screen
276
289
  with it. The three-fact fixture is what hid it. */}
277
- {/* `width` on the PANEL, not `style` `style` reaches only the body, so a
278
- long referenced name in the header stretched the panel past it and the
279
- facts sat in a box wider than themselves. `maxHeight` stays on the
280
- body: that is the band that scrolls. */}
281
- <PopoverContent width={320} style={{ maxHeight: 420 }}>
290
+ {/* No `width` here at all now: a fixed panel width is applied LAST in the
291
+ style chain, so it beats `inheritTriggerWidth` passing both would have
292
+ silently kept the 320. `maxHeight` stays on the body, which is the band
293
+ that scrolls; the header keeps its own height so a wrapped two-line name
294
+ is never what scrolls out of view. */}
295
+ <PopoverContent style={{ maxHeight: 420 }}>
282
296
  <PopoverHeader>
283
297
  <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
284
298
  </PopoverHeader>
@@ -256,7 +256,14 @@ const styles = StyleSheet.create({
256
256
  // padding stay (they hold the field's size); the horizontal padding goes,
257
257
  // because the host already spent CONTROL_TEXT_INSET and two insets stacked
258
258
  // shift the text 8px right the moment the field focuses.
259
- seamless: { borderColor: "transparent", backgroundColor: "transparent", paddingHorizontal: 0 },
259
+ //
260
+ // `borderWidth: 0`, not merely a transparent colour. A transparent border still
261
+ // OCCUPIES its 1px in the box model, so hiding the colour left the second inset
262
+ // in place at 1/8th the size: measured, the text moved 561 → 562 on focus — the
263
+ // same defect this style exists to fix, at the scale that survives a glance.
264
+ // Safe for height because the box is `border-box`: dropping the border changes
265
+ // the content box, never the field's 40px outer size.
266
+ seamless: { borderColor: "transparent", borderWidth: 0, backgroundColor: "transparent", paddingHorizontal: 0 },
260
267
  disabled: {
261
268
  color: colors.zinc["400"],
262
269
  outlineStyle: "none" as unknown as "solid",
package/src/text_utils.ts CHANGED
@@ -73,6 +73,25 @@ export const INPUT_LINE_HEIGHT_DESKTOP = 20;
73
73
  export const inputTextStyleWeb = {
74
74
  fontSize: "var(--input-font-size)",
75
75
  lineHeight: "var(--input-line-height)",
76
+ // THE INK IS PART OF THE CONTRACT, because leaving it out does not mean
77
+ // "inherit" — a native <input> ignores inherited colour and falls to the UA
78
+ // default, PURE BLACK, while every resting value beside it renders `zinc-900`.
79
+ // So a field's own text changed colour the instant it was focused: measured
80
+ // rgb(24,24,27) at rest against rgb(0,0,0) editing, on an unremarkable text
81
+ // field. Nothing moved — which is why box geometry, insets, line boxes, fonts
82
+ // and five widths all measured identical — but at 14px a darkness change
83
+ // re-weights the antialiasing of every stroke, and the eye reads that as the
84
+ // text shifting on focus. An EMPTY field hid it completely, since a placeholder
85
+ // sets its own colour and therefore never changed.
86
+ //
87
+ // It lives here rather than in one component because six inputs share this
88
+ // contract, and an ink re-picked per input is the drift that produced the bug.
89
+ // `getTextColor()` for the same reason — the default ink has ONE definition and
90
+ // an input is not the place to restate it. This is a DEFAULT, not a lock: every
91
+ // consumer applies the caller's `style` after this one, so an app that wants a
92
+ // different ink still sets it. (It is not the brand accent either way — the theme
93
+ // is deliberately accent-only, and body ink is not themed.)
94
+ color: getTextColor(),
76
95
  };
77
96
 
78
97
  /**
@@ -90,6 +109,8 @@ export const inputTextStyleRN = inputTextStyleWeb as unknown as TextStyle;
90
109
  const inputTextStyleNative: TextStyle = {
91
110
  fontSize: 14,
92
111
  lineHeight: 20,
112
+ // Same ink as the web contract above — an input's text is body ink, not black.
113
+ color: getTextColor(),
93
114
  };
94
115
 
95
116
  /**