@lotics/ui 44.14.0 → 45.2.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": "44.14.0",
3
+ "version": "45.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -169,7 +169,6 @@
169
169
  "./inline_slot": "./src/inline_slot.tsx",
170
170
  "./inline_static": "./src/inline_static.tsx",
171
171
  "./inline_files": "./src/inline_files.tsx",
172
- "./inline_markdown": "./src/inline_markdown.tsx",
173
172
  "./inline_text_input": "./src/inline_text_input.tsx",
174
173
  "./inline_number_input": "./src/inline_number_input.tsx",
175
174
  "./inline_select": "./src/inline_select.tsx",
@@ -234,6 +233,7 @@
234
233
  "./list": "./src/list.tsx",
235
234
  "./list_item": "./src/list_item.tsx",
236
235
  "./section_heading": "./src/section_heading.tsx",
236
+ "./heading_altitude": "./src/heading_altitude.ts",
237
237
  "./section_stack": "./src/section_stack.tsx",
238
238
  "./sequence": "./src/sequence.tsx",
239
239
  "./separator": "./src/separator.tsx",
package/src/accordion.tsx CHANGED
@@ -152,8 +152,8 @@ export interface AccordionMetaProps {
152
152
  export function AccordionMeta(props: AccordionMetaProps) {
153
153
  // The meta yields before the title does. The title NAMES the disclosure; the
154
154
  // meta only qualifies it — but with the title on `flex: 1` and the meta at its
155
- // intrinsic width, the squeeze landed entirely on the name (measured at 375:
156
- // title clipped 24% and ellipsised, meta untouched). Same inverted priority
155
+ // intrinsic width, the squeeze lands entirely on the name the title
156
+ // ellipsises while the meta goes untouched. Same inverted priority
157
157
  // `PageHeader` fixed one rung up; resolved the same way here.
158
158
  return (
159
159
  <Text size="xs" color="muted" tabular numberOfLines={1} style={styles.meta}>
@@ -42,7 +42,7 @@ const OVERLAP_RATIO = 0.3;
42
42
  * component overlap at all. The conventional facepile rings each mark in the
43
43
  * page background so the discs read as separate, which binds the ring to a
44
44
  * colour the row does not keep: a register row washes to `zinc[50]` on hover,
45
- * `accent_wash` as the open record and `zinc[200]` while pressed, so a white
45
+ * `zinc[100]` as the open record and `zinc[200]` while pressed, so a white
46
46
  * ring is a halo in three of the four states — and those are the states a
47
47
  * reader is in when they are actually looking at the row. A translucent dark
48
48
  * edge DARKENS whatever is behind it instead of matching it, so it holds on
@@ -104,9 +104,8 @@ const words = (name: string): string[] => name.trim().split(/\s+/).filter((w) =>
104
104
  * TWO LETTERS, AND THEY ARE THE FIRST AND **LAST** WORDS — not the first two.
105
105
  * A Vietnamese name is họ + đệm + tên, so the first two words are the family name
106
106
  * and a middle name shared by half the office: "Vũ Thị Lan" and "Vũ Thị Ngọc" both
107
- * reduce to "VT". Taking the first and last gives VL and VN. Measured on a real
108
- * 29-person workspace, first-two-words leaves 11 people indistinguishable and
109
- * first-and-last leaves 5. It is also what English display initials have always
107
+ * reduce to "VT". Taking the first and last gives VL and VN, which collides far
108
+ * less often on a real roster. It is also what English display initials have always
110
109
  * been — "Mary Jane Watson" is MW — so this is a correction, not a localization.
111
110
  *
112
111
  * `sm` gets ONE letter: two do not fit legibly at 24px, which is the whole reason
@@ -61,12 +61,13 @@ const styles = StyleSheet.create({
61
61
  ring: {
62
62
  ...({ boxShadow: FOCUS_RING } as ViewStyle),
63
63
  },
64
- // The accent wash + accent edge is the same "you are here" the selected table
65
- // row and the active chip carry, so one signal means one thing product-wide.
66
- // A ground rather than a heavier outline: among white cards a 1px border
67
- // change is noise, and it vanishes entirely under the hover ring.
64
+ // A DARK EDGE, the same one a selected chip wears this card cited the chip
65
+ // as its precedent when both used the accent, and it still follows it. Doubled
66
+ // by a boxShadow so it is two pixels of the kit's darkest ink rather than one,
67
+ // which is what the accent ground was introduced to fix and what the ground
68
+ // itself could not do without spending the brand on a selection.
68
69
  selected: {
69
- backgroundColor: colors.accent_wash,
70
- borderColor: colors.accent,
70
+ borderColor: colors.zinc[900],
71
+ ...({ boxShadow: `0 0 0 1px ${colors.zinc[900]}` } as ViewStyle),
71
72
  },
72
73
  });
@@ -337,7 +337,8 @@ export const colors = {
337
337
  */
338
338
  primary: palette.zinc["900"],
339
339
  /**
340
- * The brand's TINT — a row's hover, an active filter's ground, a header band.
340
+ * The brand's TINT — a selected row's ground, an active filter's ground, a
341
+ * header band.
341
342
  *
342
343
  * It exists as its own token because it cannot be derived where it is used:
343
344
  * `withAlpha`/`tint` do string surgery on an `rgba()`, and on web `accent` is a
@@ -345,11 +346,26 @@ export const colors = {
345
346
  * type would catch. `applyLoticsTheme` computes it instead, from the literal
346
347
  * hex the app handed it — derivation happens where the literal still exists.
347
348
  *
348
- * The default is DERIVED FROM `accent`'s default, not picked separately: the
349
- * accent is blue-600, so the wash is blue-50. It was zinc-100, which meant the
350
- * two halves of one token pair disagreed and every unthemed app got a
351
- * colourless "you are here"; a wash that contradicts its own accent is not a
352
- * neutral default, it is a second brand nobody chose.
349
+ * IT IS FOR ATTENTION, NEVER FOR SELECTION, and that line is why this comment
350
+ * is long. Every SELECTED state in the kit is neutral and says so in its own
351
+ * literal: a register row is zinc-100, a chip and a card are a doubled zinc-900
352
+ * EDGE with no ground of their own. What is left for this token is the state
353
+ * that genuinely wants the brand — a region lighting up under a dragged file,
354
+ * a moment the eye is meant to be pulled to and that no app would want in grey.
355
+ *
356
+ * The confusion cost a round trip in both directions, so the line is worth
357
+ * holding. Selections were painted through this token, and the kit shipped a
358
+ * blue nobody had chosen on its busiest surface; the token was then made
359
+ * neutral, which turned a themed app's drop target grey and made the name a
360
+ * lie. Pointing selections at their own literals fixes both at once — the token
361
+ * keeps its meaning, and "is this selected" stops depending on whether a tenant
362
+ * happened to set an accent.
363
+ *
364
+ * It is a TOKEN rather than a call-site derivation because `withAlpha`/`tint`
365
+ * do string surgery on an `rgba()`, and on web `accent` is a `var()` — asking
366
+ * for "accent at 7%" where it is used produces garbage no type would catch.
367
+ * `applyLoticsTheme` computes it from the literal hex the app handed it, which
368
+ * is the one place the literal still exists.
353
369
  */
354
370
  accent_wash: palette.blue["50"],
355
371
  shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
package/src/combobox.tsx CHANGED
@@ -534,7 +534,17 @@ export function ComboboxContent(props: ComboboxContentProps) {
534
534
  // Rich row (custom content, or a label over its description) → the growing
535
535
  // `MenuListItem`; a plain option stays the fixed-height `MenuButton`.
536
536
  return content || desc ? (
537
- <MenuListItem key={`${row.kind}-${opt.value}`} {...shared} title={content ?? label} description={content ? undefined : desc} />
537
+ <MenuListItem
538
+ key={`${row.kind}-${opt.value}`}
539
+ {...shared}
540
+ title={content ?? label}
541
+ // A custom render and a description are INDEPENDENT facts —
542
+ // see the same rule in `option_list`. `optionPicker` sets
543
+ // `renderOptionContent` whenever a badge is asked for, so
544
+ // suppressing here loses the subtitle for any caller who
545
+ // wanted both, silently.
546
+ description={desc}
547
+ />
538
548
  ) : (
539
549
  <MenuButton
540
550
  key={`${row.kind}-${opt.value}`}
@@ -190,22 +190,28 @@ export function chipSurfaceStyle(
190
190
  justifyContent: "center",
191
191
  borderRadius: CONTROL_RADIUS,
192
192
  borderWidth: 1,
193
- // A SELECTED chip needs a GROUND, not a heavier outline. This drew
194
- // zinc-900 at 2px on a white fill — and in a toolbar where every sibling is
195
- // also a white pill with a 1px border, "which filter is on" came down to a
196
- // one-pixel colour change that reads as noise at a glance. The accent wash
197
- // is the same "you are here" ground the active tab and the selected row
198
- // carry, so one signal means one thing across the product; it also stays
199
- // clear of the primary button's near-black fill, which a dark chip would
200
- // have collided with two controls away.
201
- borderColor: selected ? colors.accent : colors.border,
202
- backgroundColor: selected
203
- ? colors.accent_wash
204
- : state.pressed
205
- ? colors.zinc[200]
206
- : state.hovered
207
- ? colors.zinc[100]
208
- : colors.white,
193
+ // A SELECTED chip is a dark EDGE, and its ground is the ground of every other
194
+ // chip. The edge is `zinc[900]` doubled by a boxShadow the kit's focus-ring
195
+ // ink and weight so "which one is on" is answered by the same treatment
196
+ // that answers "where am I", learned once and used everywhere.
197
+ //
198
+ // It carried a ground for a while instead, on the argument that among white
199
+ // pills a one-pixel border change reads as noise. Two things were wrong with
200
+ // that. A ground is the row's language and a chip is a CONTROL, so the two
201
+ // altitudes started saying the same thing in the same paint; and it spent the
202
+ // brand on a selection, which then had to be walked back everywhere at once.
203
+ // The doubled edge is not one pixel — it is two, in the darkest ink the kit
204
+ // has, and it survives the hover wash instead of being replaced by it.
205
+ //
206
+ // boxShadow rather than a thicker `borderWidth`: a border grows the box, so a
207
+ // chip would reflow its neighbours on selection.
208
+ borderColor: selected ? colors.zinc[900] : colors.border,
209
+ backgroundColor: state.pressed
210
+ ? colors.zinc[200]
211
+ : state.hovered
212
+ ? colors.zinc[100]
213
+ : colors.white,
214
+ ...(selected ? ({ boxShadow: `0 0 0 1px ${colors.zinc[900]}` } as ViewStyle) : null),
209
215
  };
210
216
  }
211
217
 
@@ -126,9 +126,7 @@ export function DateStamp(props: DateStampProps) {
126
126
  // an icon has no equivalent of, so the label's optical centre (its
127
127
  // cap band) sits ~2px above the box centre. Matching box centres
128
128
  // therefore hangs the icon low against the text every time.
129
- //
130
- // Measured on this row: label box 344–364, cap band ~348–358. The
131
- // lift is that gap, not a taste adjustment.
129
+ // The lift is that gap, not a taste adjustment.
132
130
  <View style={{ height: 16, justifyContent: "center", alignSelf: "center", marginBottom: 2 }}>
133
131
  <Icon name="calendar" size={13} color={hovered ? colors.zinc[500] : colors.zinc[400]} />
134
132
  </View>
@@ -249,8 +249,7 @@ const styles = StyleSheet.create({
249
249
  // `flex: 1` — grow 1, shrink 1, basis **0** — so the label entered the layout
250
250
  // claiming no size at all, and a value too wide for the row (a company name in
251
251
  // a stacked register) took the whole row while the label absorbed the entire
252
- // deficit: measured at 0px wide and 140px tall, "Hợp đồng" set one character
253
- // per line down the page.
252
+ // deficit and set one character per line down the page.
254
253
  //
255
254
  // With `basis: auto` the label enters at its TEXT width, so a deficit is split
256
255
  // between the two in proportion to what each actually asked for — a long value
@@ -260,9 +259,9 @@ const styles = StyleSheet.create({
260
259
  // Spread mode's label. It does NOT shrink, and that is the whole rule: both
261
260
  // items in this row are floorless under React Native Web (which defaults flex
262
261
  // children to `min-width: 0`, unlike the web's `auto`), so with shrink on both
263
- // the flex algorithm splits the deficit and the LABEL loses — measured on a
264
- // narrow register, a two-word column name collapsed to a 4px box 200px tall,
265
- // one character per line, while its neighbour with a short value measured a
262
+ // the flex algorithm splits the deficit and the LABEL loses — a two-word
263
+ // column name collapses to a sliver set one character per line, while its
264
+ // neighbour with a short value keeps a
266
265
  // clean 251x20. A label the reader cannot finish makes the row meaningless,
267
266
  // and the value beside it wraps for free (`spreadValue` already carries the
268
267
  // `minWidth: 0` that lets it), so the value is the one that gives way.
package/src/dialog.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  useRouteRegistry,
17
17
  useNavigationStack,
18
18
  } from "./screen_router";
19
+ import { HeadingAltitudeContext } from "./heading_altitude";
19
20
 
20
21
  // ============================================================================
21
22
  // Shared Navigation Context (used by both Dialog and MasterDetailDialog)
@@ -104,9 +105,9 @@ function dialogGutterFor(small: boolean): number {
104
105
  *
105
106
  * Centralising the VALUE was not enough, and that mistake shipped: `dialogGutter(small)` still
106
107
  * left every band to work out `small` for itself, and `AgentRunPane` asked
107
- * `useContainerSize()` (its own measured box, "small" at a 760px dialog) while the header,
108
- * scroll area and footer asked `useScreenSize()` (the viewport, not small). Two honest answers
109
- * to one question put the run's rows 8px left of the dialog's own title.
108
+ * `useContainerSize()` (its own box) while the header, scroll area and footer asked
109
+ * `useScreenSize()` (the viewport). Two honest answers to one question left the run's rows
110
+ * out of line with the dialog's own title.
110
111
  *
111
112
  * So the Dialog decides ONCE and publishes it. A band inside a dialog gets that number
112
113
  * whatever its own box measures; a surface rendered outside one falls back to the SCREEN — the
@@ -285,10 +286,16 @@ export function DialogScrollArea(props: DialogScrollAreaProps) {
285
286
  const { children } = props;
286
287
  const gutter = useDialogGutter();
287
288
 
289
+ // The gutter and the heading altitude are the same kind of fact — both belong
290
+ // to the panel, and both were being answered by callers who could only guess.
291
+ // A `SectionHeadingTitle` in here renders the ramp's `####` rung, under the
292
+ // `lg` `DialogHeaderTitle` above it. See `heading_altitude.ts`.
288
293
  return (
289
- <ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}>
290
- {children}
291
- </ScrollView>
294
+ <HeadingAltitudeContext.Provider value="panel">
295
+ <ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}>
296
+ {children}
297
+ </ScrollView>
298
+ </HeadingAltitudeContext.Provider>
292
299
  );
293
300
  }
294
301
 
package/src/drawer.tsx CHANGED
@@ -8,6 +8,7 @@ import { IconButton } from "@lotics/ui/icon_button";
8
8
  import { Text } from "@lotics/ui/text";
9
9
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
10
10
  import { useLoticsLocale } from "@lotics/ui/locale";
11
+ import { HeadingAltitudeContext } from "./heading_altitude";
11
12
 
12
13
  /**
13
14
  * The panel's inset — the ONE left edge the header, `DrawerScrollArea` and the footer
@@ -167,9 +168,8 @@ export interface DrawerScrollAreaProps {
167
168
  * its title was inset 20.
168
169
  *
169
170
  * It ALWAYS scrolls, and there is deliberately no flag to stop it. A padded box
170
- * holding a `flex:1` scroller insets that scroller's VIEWPORT measured, a 400px
171
- * panel ends its scroll at 380 so the list clips short of the panel with dead
172
- * space beneath it. A surface that genuinely needs a pinned region above a
171
+ * holding a `flex:1` scroller insets that scroller's VIEWPORT, so the list clips
172
+ * short of the panel with dead space beneath it. A surface that genuinely needs a pinned region above a
173
173
  * scrolling one is not this component: it goes in the `Drawer`'s bare slot and
174
174
  * puts `DRAWER_GUTTER` on the pinned part's padding AND on its scroller's
175
175
  * `contentContainerStyle`, which is the only arrangement where content still
@@ -182,7 +182,21 @@ export interface DrawerScrollAreaProps {
182
182
  * does, and would force every full-bleed child to cancel it with a negative margin.
183
183
  */
184
184
  export function DrawerScrollArea(props: DrawerScrollAreaProps) {
185
- return <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>;
185
+ // It publishes the panel's HEADING ALTITUDE as well as its gutter, and for the
186
+ // same reason: this is the region whose left edge and whose outline both
187
+ // belong to the panel. A `SectionHeadingTitle` in here steps to the ramp's
188
+ // `####` rung, under the `lg` title in the bar above it, without the caller
189
+ // naming the container it is in — that naming is what nine apps out of nine
190
+ // got wrong, and the container never has to be told.
191
+ //
192
+ // Not on `Drawer` itself: the drawer's BARE slot is where a whole record
193
+ // screen goes, and that surface brings its own `#` identity band, so its
194
+ // sections are page sections and must stay `##`. See `heading_altitude.ts`.
195
+ return (
196
+ <HeadingAltitudeContext.Provider value="panel">
197
+ <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>
198
+ </HeadingAltitudeContext.Provider>
199
+ );
186
200
  }
187
201
 
188
202
  export interface DrawerFooterProps {
@@ -91,8 +91,7 @@ export function FilterChip(props: FilterChipProps) {
91
91
  // bring their own bottom actions — an `OptionList` with `enableSelectAll`
92
92
  // renders "Select all / Deselect all" in a bordered band of its own — so the
93
93
  // popover ended with TWO stacked rules whose lower one held a single button
94
- // that "Deselect all" directly above it already performed. Measured on a live
95
- // filter: bands at 49px and 47px, each with its own 1px top border.
94
+ // that "Deselect all" directly above it already performed.
96
95
  //
97
96
  // The × is also the more discoverable of the two: it sits on the pill, which
98
97
  // is on screen whether or not the popover is open, and it carries `clearLabel`
package/src/finding.tsx CHANGED
@@ -164,16 +164,33 @@ export function Finding(props: FindingProps) {
164
164
 
165
165
  /** The source behind a reading. Pressable only when the host can navigate to
166
166
  * it — an affordance on something that goes nowhere is a broken promise. */
167
+ /**
168
+ * A reading's source — the caption naming WHERE the value came from, and, when
169
+ * the host can open it, the way there.
170
+ *
171
+ * The two states are treated differently ON PURPOSE. A source that opens
172
+ * something is a control and wears navigation ink: default colour, `medium`,
173
+ * underlined. A source that opens nothing stays a caption: `muted`, regular, no
174
+ * rule. They used to render IDENTICALLY — muted caption text, with the press
175
+ * target announced only to a screen reader and revealed to everyone else on
176
+ * hover. A control nobody can see is not reachable in any sense that counts, and
177
+ * the inverse mistake (a caption dressed as a link, so a reader keeps pressing
178
+ * text that does nothing) is the same failure read from the other end. What a
179
+ * control looks like has to promise what pressing it does.
180
+ */
167
181
  function SourceName({ label, onOpen }: { label: string; onOpen?: (source: string) => void }) {
168
- const name = (
169
- <Text size="sm" color="muted" numberOfLines={1}>
170
- {label}
171
- </Text>
172
- );
173
- if (!onOpen) return name;
182
+ if (!onOpen) {
183
+ return (
184
+ <Text size="sm" color="muted" numberOfLines={1}>
185
+ {label}
186
+ </Text>
187
+ );
188
+ }
174
189
  return (
175
190
  <PressableHighlight onPress={() => onOpen(label)} accessibilityRole="link" accessibilityLabel={label}>
176
- {name}
191
+ <Text size="sm" weight="medium" decoration="underline" numberOfLines={1}>
192
+ {label}
193
+ </Text>
177
194
  </PressableHighlight>
178
195
  );
179
196
  }
@@ -0,0 +1,65 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * WHICH SURFACE a heading is standing on — the one fact the fixed heading ramp
5
+ * needs, and the one fact a caller should never have to restate.
6
+ *
7
+ * `page` — the flat canvas. A `#` identity band (`PageHeader`, `RecordSummary`)
8
+ * tops the outline, so a section title is the `##` below it.
9
+ *
10
+ * `panel` — a self-contained pane whose own chrome title caps at `lg`, or which
11
+ * has no title at all: a `Dialog`, `Drawer`, `Modal` or `Popover` body, and
12
+ * equally a side panel docked beside a page. A `##` renders at 24px there, over
13
+ * a 20px bar — measured, and identical everywhere, because the ramp had no way
14
+ * to know which surface it was on and the caller had no reason to think it
15
+ * mattered.
16
+ *
17
+ * A parallel per-surface heading component does not fix that; it only moves the
18
+ * question to the call site, where the caller still has to know what is holding
19
+ * their heading, and answers wrong for the same reason they never noticed the
20
+ * inversion. So the CONTAINER answers instead. It is the only party that knows,
21
+ * and it knows without being told.
22
+ */
23
+ export type HeadingAltitude = "page" | "panel";
24
+
25
+ /**
26
+ * Published by the CONTENT REGIONS — `DrawerScrollArea`, `DialogScrollArea`,
27
+ * `ModalBody`, `PopoverContent` — and NOT by `Drawer`, `Dialog` or `Modal`
28
+ * themselves.
29
+ *
30
+ * That is deliberate, and it is the whole reason the split works. An overlay is
31
+ * used two ways: as a PANEL, whose chrome title is the top of its outline, and
32
+ * as a FRAME around a whole page surface that brings its own `#` identity band
33
+ * (the master-detail register — a record screen docked in a drawer). The panel
34
+ * has to step down; the page surface must not, or its `##` sections drop below
35
+ * the `###` subsections nested inside them.
36
+ *
37
+ * The kit already distinguishes those two, and it distinguishes them exactly
38
+ * here: the content regions own the container's gutter, and a body that carries
39
+ * its own gutters — "a full-bleed band, a whole record screen" — takes the
40
+ * container's bare slot instead and skips them. So the marker already exists,
41
+ * already means what we need it to mean, and is already load-bearing enough that
42
+ * it cannot be dropped by accident.
43
+ *
44
+ * A body that uses neither keeps `page` and renders exactly what it renders
45
+ * today. That is the safe direction to be wrong in: the fix arrives when the
46
+ * body wraps in the region the kit already asks it to wrap in, and a surface the
47
+ * kit cannot see is never silently re-ranked.
48
+ *
49
+ * **A host that builds its own pane publishes it directly** — wrap the pane's
50
+ * content in `<HeadingAltitudeContext.Provider value="panel">`. A docked side
51
+ * panel is the case: it is not an overlay, it uses none of the regions above,
52
+ * and its headings still must not out-shout a pane 400px wide. Scoping the rule
53
+ * to the containers the KIT happens to render would make it an allowlist that is
54
+ * silently short by however many panes the host builds itself.
55
+ *
56
+ * The value is FLAT, never a depth counter. A popover opened from inside a
57
+ * drawer is still one rung down, not two — the ramp is absolute per altitude,
58
+ * and nesting panes must not invent a fifth rung.
59
+ */
60
+ export const HeadingAltitudeContext = createContext<HeadingAltitude>("page");
61
+
62
+ /** The altitude of the surface this heading is on. `page` unless a pane says otherwise. */
63
+ export function useHeadingAltitude(): HeadingAltitude {
64
+ return useContext(HeadingAltitudeContext);
65
+ }
@@ -54,8 +54,8 @@ export interface InlineTextInputProps {
54
54
  * a payment term — where reserving the space keeps the page still. It is wrong
55
55
  * for open prose, and it fails in the worst way: the field renders a box the
56
56
  * value does not fit, with no ellipsis, no scrollbar and no scroll, so the
57
- * reader is given no evidence that anything is missing. Measured on a record
58
- * note, 285 characters drew 76px of a 116px value — two lines gone, silently.
57
+ * reader is given no evidence that anything is missing a long note simply
58
+ * loses its tail.
59
59
  *
60
60
  * Growing costs nothing this control was protecting: the field is ONE input in
61
61
  * both states, so a grown box is the same height resting and editing, and
@@ -139,9 +139,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
139
139
  // same string identically, and nothing enforces it: the padding drifted
140
140
  // (8px), then the transparent border (1px), then the ink (zinc-900 vs the
141
141
  // UA's black) — each fixed in turn, each revealing the next. The last one
142
- // cannot be fixed at all: measured on an interior 4x crop with no border or
143
- // ring in frame, the glyph mass sits 1 DEVICE pixel lower while editing
144
- // (row centroid 12.985 -> 13.985, identical ink, identical column
142
+ // cannot be fixed at all: the glyph mass sits one DEVICE pixel lower while
143
+ // editing (identical ink, identical column
145
144
  // centroid). Both paths compute a 10px text top, so it is not a padding
146
145
  // mistake — an `<input>` centres its text by FONT METRICS and a `<div>`
147
146
  // positions it by LINE BOX, and the residual is a quarter of a CSS pixel.
package/src/ledger.tsx CHANGED
@@ -257,9 +257,9 @@ export function LedgerRow(props: LedgerRowProps) {
257
257
  const rowDetails = useLoticsLocale().ledger.rowDetails;
258
258
  const { format, dropMeta } = useLedger();
259
259
  // The caption goes entirely rather than shrinking to nothing. Yielding first
260
- // (below) is the right ORDER but not a floor: measured at 375, a 30-character
261
- // caption still wanted 184px of a ~250px text budget and clipped and took
262
- // the label down with it, ellipsising a 74px identity at 71px. Two separate
260
+ // (below) is the right ORDER but not a floor: at phone width a long caption
261
+ // still claims most of the text budget, clips, and takes the label down with
262
+ // it. Two separate
263
263
  // authors had already worked around this by dropping `meta` at small widths
264
264
  // in their own apps, which is the component's job.
265
265
  const showMeta = meta != null && meta !== "" && !dropMeta;
@@ -34,12 +34,9 @@ const markdownComponents = {
34
34
  * Sizing the headings down fixes what a screen SHOWS and nothing about what it
35
35
  * ANNOUNCES: an `h2` the author of the text happened to write is still an `h2`,
36
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
37
+ * A model's call-summary title then sits in the heading outline between two of
38
+ * the page's real sections, from inside one row of a feed that can hold twenty
39
+ * more. No visual probe finds this, and
43
40
  * fixing the size is what makes it invisible: the defect stops looking wrong at
44
41
  * the exact moment it stops being measurable.
45
42
  *
package/src/matrix.tsx CHANGED
@@ -135,7 +135,7 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
135
135
  <View style={styles.headRow}>
136
136
  <View style={[styles.rowLabel, { width: rowLabelWidth }]}>
137
137
  {typeof corner === "string" ? (
138
- <Text size="xs" color="muted" numberOfLines={1}>
138
+ <Text size="sm" color="muted" weight="medium" numberOfLines={1}>
139
139
  {corner}
140
140
  </Text>
141
141
  ) : (
package/src/modal.tsx CHANGED
@@ -6,6 +6,7 @@ import { Text } from "@lotics/ui/text";
6
6
  import { PortalHost } from "@lotics/ui/portal";
7
7
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
8
8
  import { useLoticsLocale } from "@lotics/ui/locale";
9
+ import { HeadingAltitudeContext } from "./heading_altitude";
9
10
 
10
11
  export interface ModalProps {
11
12
  open: boolean;
@@ -100,7 +101,15 @@ export interface ModalBodyProps {
100
101
  */
101
102
  export function ModalBody(props: ModalBodyProps) {
102
103
  const { children } = props;
103
- return <ScrollView contentContainerStyle={styles.bodyContent}>{children}</ScrollView>;
104
+ // A takeover is still a panel: `ModalHeader`'s title is `lg`, so a section
105
+ // heading inside the body takes the ramp's `####` rung. Same rule as the
106
+ // drawer and the dialog, published from the same kind of region — the one
107
+ // that owns the surface's padding. See `heading_altitude.ts`.
108
+ return (
109
+ <HeadingAltitudeContext.Provider value="panel">
110
+ <ScrollView contentContainerStyle={styles.bodyContent}>{children}</ScrollView>
111
+ </HeadingAltitudeContext.Provider>
112
+ );
104
113
  }
105
114
 
106
115
  export interface ModalFooterProps {