@lotics/ui 44.13.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/docs/reviewing.md CHANGED
@@ -15,17 +15,25 @@ Everything here is about a screen **that already renders**. Building one starts
15
15
 
16
16
  ## The method
17
17
 
18
- **"Looks off" is a hypothesis, not a finding.** An impression cannot be compared across a screen
19
- or re-checked after a fix. Extract computed values and let the table show the defect. Every
20
- finding cites numbers.
18
+ **"Looks off" is where a finding starts.** Trust the impression it is the same reaction the
19
+ reader will have and use measurement to say precisely WHAT is off, so the fix can be aimed and
20
+ re-checked. Numbers are how a finding is confirmed and communicated, never what makes it real.
21
+
22
+ The failure this doc has caused more than once: a screen passes every probe, the auditor reports
23
+ that it measures fine, and the person who called it ugly is still right. Defect-removal converges
24
+ on *inoffensive*. When the probes come back clean and the screen is still wrong, the probes are
25
+ not the answer — the form, the density, or the fact that nothing on screen belongs to this product
26
+ is. Go and look at it.
21
27
 
22
28
  1. **Render it** — `lotics app dev` for an app, a preview route for a screen. Drive it with
23
29
  browser automation that can evaluate JS in the page.
24
- 2. **Run the two gates**, then the probes.
25
- 3. **Fix, then re-measure.** A design fix is verified the way it was found.
26
- 4. **Screenshot too.** Numbers say *what* is wrong; a picture says *that* something is, and some
27
- defects live only in the picture — a mark floating over a tile that never loaded measures
28
- perfectly and is obviously broken on sight. Take it even when the numbers are clean.
30
+ 2. **Look at it first.** Form a view before running anything, and write it down. That view is
31
+ what the probes are being asked to explain.
32
+ 3. **Run the gates**, then reach for whichever probes speak to what you saw. They are a
33
+ diagnostic kit, not a checklist to complete — a screen that needs three of them needs three.
34
+ 4. **Fix, then re-check** the way you found it.
35
+ 5. **Screenshot.** Some defects live only in the picture — a mark floating over a tile that never
36
+ loaded measures perfectly and is obviously broken on sight.
29
37
 
30
38
  **Map raw CSS back to the ladder as you go.** A finding reads as `xs/500/muted`, not
31
39
  `12px/500/rgb(82,82,91)`, so the treatment classes are visible at a glance. The rungs are in
@@ -619,10 +627,14 @@ has three gotchas of its own: [testing.md](./testing.md).
619
627
 
620
628
  ## What measurement cannot see
621
629
 
622
- A screenshot shows *that* something is wrong when every number looks defensible. But it will not
623
- show you a disclosure you did not expand, or copy that only lies in its terminal state (a settled
624
- row still saying "Loading…" measures perfectly). Nor a flattering fixture compare the preview
625
- data's SHAPE against what the real source emits before trusting any of it.
630
+ Most of what makes a screen good, which is why the probes are a floor and not a target. They will
631
+ not tell you the density is wrong for the job, that the register has no point of view, or that
632
+ nothing on the page belongs to this product rather than to any product. Those are judgements, and
633
+ they are the ones that decide whether the work reads as considered.
634
+
635
+ Nor will they show a disclosure you did not expand, copy that only lies in its terminal state (a
636
+ settled row still saying "Loading…" measures perfectly), or a flattering fixture — compare the
637
+ preview data's SHAPE against what the real source emits before trusting any of it.
626
638
 
627
639
  ---
628
640
 
package/docs/templates.md CHANGED
@@ -680,7 +680,7 @@ billing, and quick-capture templates. Top → bottom:
680
680
  - **The page layout is the DOCS layout — the reading column is CENTRED IN THE VIEWPORT, and
681
681
  the rail sits in its left gutter.** The `ScrollView` is FULL-WIDTH — **never cap it at
682
682
  `CONTENT_MAX`, or the scroll container becomes the column and the gutters go wheel-dead**
683
- (measured: a 720px scroller in a 1440px window leaves half the page ignoring the wheel).
683
+ (a capped scroller leaves the page either side of it ignoring the wheel).
684
684
  Inside it sits one centred row (`width: "100%"`,
685
685
  `maxWidth: GUTTER * 2 + CONTENT_MAX`, `alignSelf: "center"`, `flexDirection: "row"`) of
686
686
  three parts: a gutter RESERVED for the rail (`width: GUTTER` — the rail's own `RAIL_W`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.13.0",
3
+ "version": "45.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -233,6 +233,7 @@
233
233
  "./list": "./src/list.tsx",
234
234
  "./list_item": "./src/list_item.tsx",
235
235
  "./section_heading": "./src/section_heading.tsx",
236
+ "./heading_altitude": "./src/heading_altitude.ts",
236
237
  "./section_stack": "./src/section_stack.tsx",
237
238
  "./sequence": "./src/sequence.tsx",
238
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
+ }
package/src/icon.tsx CHANGED
@@ -132,8 +132,12 @@ import Maximize2 from "lucide-react-native/dist/esm/icons/maximize-2";
132
132
  import Megaphone from "lucide-react-native/dist/esm/icons/megaphone";
133
133
  import Mic from "lucide-react-native/dist/esm/icons/mic";
134
134
  import Menu from "lucide-react-native/dist/esm/icons/menu";
135
+ import Facebook from "lucide-react-native/dist/esm/icons/facebook";
136
+ import Instagram from "lucide-react-native/dist/esm/icons/instagram";
137
+ import Linkedin from "lucide-react-native/dist/esm/icons/linkedin";
135
138
  import MessageCircle from "lucide-react-native/dist/esm/icons/message-circle";
136
139
  import MessageCircleQuestionMark from "lucide-react-native/dist/esm/icons/message-circle-question-mark";
140
+ import Twitter from "lucide-react-native/dist/esm/icons/twitter";
137
141
  import MessageSquare from "lucide-react-native/dist/esm/icons/message-square";
138
142
  import MessagesSquare from "lucide-react-native/dist/esm/icons/messages-square";
139
143
  import Minimize2 from "lucide-react-native/dist/esm/icons/minimize-2";
@@ -328,8 +332,12 @@ const iconComponents = {
328
332
  "maximize-2": Maximize2,
329
333
  megaphone: Megaphone,
330
334
  menu: Menu,
335
+ facebook: Facebook,
336
+ instagram: Instagram,
337
+ linkedin: Linkedin,
331
338
  "message-circle": MessageCircle,
332
339
  "message-circle-question-mark": MessageCircleQuestionMark,
340
+ twitter: Twitter,
333
341
  "message-square": MessageSquare,
334
342
  "messages-square": MessagesSquare,
335
343
  mic: Mic,
@@ -167,8 +167,15 @@ interface InlineEditFrameProps {
167
167
  * which is the truth for every editor whose input arrives with the edit.
168
168
  */
169
169
  editOpen?: boolean;
170
- /** The formatted current value, shown in view mode. Empty → placeholder. */
171
- display: string;
170
+ /** The current value as the view shows it. Empty string → placeholder.
171
+ *
172
+ * A NODE renders as-is, which is what a SWAPPING editor needs when its
173
+ * resting form is not text — rendered markdown against its own source. That
174
+ * is admissible for the same reason number/date swap: the string genuinely
175
+ * CHANGES between the two states, so the sub-pixel drift an always-input
176
+ * exists to avoid is not perceptible. `InlineEditView` already renders both;
177
+ * this only stops the frame from narrowing what it forwards. */
178
+ display: string | ReactNode;
172
179
  placeholder?: string;
173
180
  /** Enter edit mode (the view is pressed). */
174
181
  onBegin: () => void;
@@ -760,7 +767,11 @@ const styles = StyleSheet.create({
760
767
  },
761
768
  placeholder: { color: colors.zinc[400] },
762
769
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
763
- viewNode: { flex: 1, minWidth: 0 },
770
+ // `textAlign` is not cosmetic here: the resting box is a button-role Pressable,
771
+ // so the UA stylesheet centres every string inside it. A STRING display never
772
+ // showed it — `Text` sets its own alignment — so the centring only surfaced
773
+ // once `display` accepted a node and rendered prose that inherited it.
774
+ viewNode: { flex: 1, minWidth: 0, textAlign: "left" },
764
775
  editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
765
776
  editControl: { flex: 1, position: "relative" },
766
777
  // The input brings its own 40px height, so the surface must not add its resting