@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/src/popover.tsx CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from "./popover_layers";
24
24
  import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
25
25
  import { useLoticsLocale } from "./locale";
26
+ import { HeadingAltitudeContext } from "./heading_altitude";
26
27
 
27
28
  export type PopoverSide = "top" | "right" | "bottom" | "left";
28
29
  export type PopoverAlign = "start" | "center" | "end";
@@ -607,155 +608,163 @@ export function PopoverContent(props: PopoverContentProps) {
607
608
  const contentZIndex = baseZIndex + nestingLevel * 2 + 1;
608
609
 
609
610
  return (
610
- <Portal>
611
- {/* Modal scrim ONLY for the bottom-sheet (`small`) mode, which IS modal.
612
- The anchored popover is NON-MODAL: no overlay, so the rest of the page
613
- stays interactive; outside-dismiss is the click listener above. */}
614
- {small && (
611
+ // A popover is dialog-scale — a few hundred px with its own chrome — so a
612
+ // heading inside it takes the ramp's `####` rung, exactly as one inside a
613
+ // dialog does. The provider covers ALL THREE BANDS, not just the scroller:
614
+ // `PopoverHeader` is a slot the CALLER composes (unlike a Drawer's title,
615
+ // which is chrome the container renders), and the identity line of a peek
616
+ // lives there. See `heading_altitude.ts`.
617
+ <HeadingAltitudeContext.Provider value="panel">
618
+ <Portal>
619
+ {/* Modal scrim — ONLY for the bottom-sheet (`small`) mode, which IS modal.
620
+ The anchored popover is NON-MODAL: no overlay, so the rest of the page
621
+ stays interactive; outside-dismiss is the click listener above. */}
622
+ {small && (
623
+ <div
624
+ style={{
625
+ position: "fixed",
626
+ top: 0,
627
+ left: 0,
628
+ right: 0,
629
+ bottom: 0,
630
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
631
+ opacity: isBottomSheetShown ? 1 : 0,
632
+ transition: "opacity 0.3s ease",
633
+ zIndex: overlayZIndex,
634
+ pointerEvents: "auto",
635
+ }}
636
+ onClick={handleOverlayClick}
637
+ onMouseDown={(e) => e.stopPropagation()}
638
+ />
639
+ )}
640
+ {/* Popover */}
615
641
  <div
642
+ ref={popoverRef}
643
+ data-popover="true"
644
+ data-popover-level={nestingLevel}
645
+ data-testid={testID}
646
+ role="dialog"
647
+ aria-modal={small ? true : undefined}
648
+ tabIndex={small ? undefined : -1}
616
649
  style={{
617
650
  position: "fixed",
618
- top: 0,
619
- left: 0,
620
- right: 0,
621
- bottom: 0,
622
- backgroundColor: "rgba(0, 0, 0, 0.5)",
623
- opacity: isBottomSheetShown ? 1 : 0,
624
- transition: "opacity 0.3s ease",
625
- zIndex: overlayZIndex,
626
- pointerEvents: "auto",
627
- }}
628
- onClick={handleOverlayClick}
629
- onMouseDown={(e) => e.stopPropagation()}
630
- />
631
- )}
632
- {/* Popover */}
633
- <div
634
- ref={popoverRef}
635
- data-popover="true"
636
- data-popover-level={nestingLevel}
637
- data-testid={testID}
638
- role="dialog"
639
- aria-modal={small ? true : undefined}
640
- tabIndex={small ? undefined : -1}
641
- style={{
642
- position: "fixed",
643
- padding: PANEL_INSET,
644
- borderTopLeftRadius: 16,
645
- borderTopRightRadius: 16,
646
- borderBottomLeftRadius: small ? 0 : 16,
647
- borderBottomRightRadius: small ? 0 : 16,
648
- backgroundColor: colors.background,
649
- boxShadow: colors.shadow,
650
- boxSizing: "border-box",
651
- zIndex: contentZIndex,
652
- transition: small ? "transform 0.3s ease" : undefined,
653
- ...(small
654
- ? {
655
- bottom: 0,
656
- left: 0,
657
- right: 0,
658
- maxHeight: "90vh",
659
- display: "flex",
660
- flexDirection: "column",
661
- paddingBottom: 32,
662
- transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
663
- }
664
- : position
651
+ padding: PANEL_INSET,
652
+ borderTopLeftRadius: 16,
653
+ borderTopRightRadius: 16,
654
+ borderBottomLeftRadius: small ? 0 : 16,
655
+ borderBottomRightRadius: small ? 0 : 16,
656
+ backgroundColor: colors.background,
657
+ boxShadow: colors.shadow,
658
+ boxSizing: "border-box",
659
+ zIndex: contentZIndex,
660
+ transition: small ? "transform 0.3s ease" : undefined,
661
+ ...(small
665
662
  ? {
666
- top: position.top,
667
- left: position.left,
668
- width: "max-content",
669
- maxWidth: 800,
670
- maxHeight: "80vh",
663
+ bottom: 0,
664
+ left: 0,
665
+ right: 0,
666
+ maxHeight: "90vh",
671
667
  display: "flex",
672
668
  flexDirection: "column",
669
+ paddingBottom: 32,
670
+ transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
673
671
  }
674
- : {
675
- top: -9999,
676
- left: -9999,
677
- width: "max-content",
678
- maxWidth: 800,
679
- maxHeight: "80vh",
680
- display: "flex",
681
- flexDirection: "column",
682
- }),
683
- // INHERIT means inherit — the width, not just a floor under it. The
684
- // flag is set only by a popover anchored to a FIELD (Select,
685
- // InlineSelect, Combobox), and a field's own width is the right width
686
- // for the list of values that field can hold: option text longer than
687
- // the field belongs on a second line, not on a wider panel.
688
- //
689
- // As a floor alone this LOOKED right, because with short options
690
- // max-content lands on the trigger width anyway. It broke the moment
691
- // an option was a sentence: the panel grew to the `maxWidth: 800` cap
692
- // and then slid sideways to stay on screen, so a 371px field opened an
693
- // 800px list starting 421px to its left, outside the drawer that owns
694
- // the field.
695
- //
696
- // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger a
697
- // a select in a dense grid column still opens a list
698
- // wide enough to read, rather than inheriting a width nothing fits in.
699
- ...(inheritTriggerWidth &&
700
- !small &&
701
- triggerWidth > 0 && {
702
- width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
703
- maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
704
- }),
705
- // Last, so it beats the `max-content` every branch above sets.
706
- ...(width != null && !small ? { width, maxWidth: width } : null),
707
- }}
708
- onClick={(e) => e.stopPropagation()}
709
- // React synthetic events bubble through portals via the REACT tree, so a
710
- // keydown inside this popover — or inside a Modal/Alert opened from it,
711
- // which is a React child even though it portals to document.body — would
712
- // reach the TRIGGER's ancestors (e.g. a grid cell's Escape-cancels-edit
713
- // onKeyDown) and let a lower layer act on a higher layer's keys, on
714
- // keydown, before any keyup layering logic runs. Keyboard sibling of the
715
- // click curtain above. Keyup deliberately keeps flowing: RN-web Modal
716
- // closes on a document-level keyup listener, and this popover's own
717
- // Escape handling is a document capture keyup — neither must be starved.
718
- onKeyDown={(e) => e.stopPropagation()}
719
- >
720
- {small && (
721
- <View
722
- style={{
723
- padding: 8,
724
- flexDirection: "row",
725
- justifyContent: "flex-end",
726
- }}
727
- >
728
- <IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
729
- </View>
730
- )}
731
- {header}
732
- {disableBodyScroll ? (
733
- <SizeBoundary style={style}>{bodyChildren}</SizeBoundary>
734
- ) : (
735
- // FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
736
- // pads all four sides, so a scroller sitting inside that padding
737
- // clips its content 12px short of the edge and parks the scrollbar
738
- // there too content slides under an invisible margin instead of
739
- // under the panel's own edge. Pulling out and padding back keeps the
740
- // text on the exact column the header and footer use while giving the
741
- // scroll its real edges. `PopoverFooter` already does this dance for
742
- // its divider; the body needed it for the same reason.
743
- //
744
- // Horizontal only: the vertical padding is the gap to the header and
745
- // footer, which is a gap the reader wants.
746
- <ScrollView
747
- style={[SCROLL_BODY, style]}
748
- contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
749
- >
750
- {/* A popover is a box of its own a few hundred px — so its contents
751
- size to the panel rather than to whatever region it was opened
752
- from. Both body paths get it; one of them is not a boundary. */}
753
- <SizeBoundary>{bodyChildren}</SizeBoundary>
754
- </ScrollView>
755
- )}
756
- {footer}
757
- </div>
758
- </Portal>
672
+ : position
673
+ ? {
674
+ top: position.top,
675
+ left: position.left,
676
+ width: "max-content",
677
+ maxWidth: 800,
678
+ maxHeight: "80vh",
679
+ display: "flex",
680
+ flexDirection: "column",
681
+ }
682
+ : {
683
+ top: -9999,
684
+ left: -9999,
685
+ width: "max-content",
686
+ maxWidth: 800,
687
+ maxHeight: "80vh",
688
+ display: "flex",
689
+ flexDirection: "column",
690
+ }),
691
+ // INHERIT means inherit the width, not just a floor under it. The
692
+ // flag is set only by a popover anchored to a FIELD (Select,
693
+ // InlineSelect, Combobox), and a field's own width is the right width
694
+ // for the list of values that field can hold: option text longer than
695
+ // the field belongs on a second line, not on a wider panel.
696
+ //
697
+ // As a floor alone this LOOKED right, because with short options
698
+ // max-content lands on the trigger width anyway. It broke the moment
699
+ // an option was a sentence: the panel grew to the `maxWidth: 800` cap
700
+ // and then slid sideways to stay on screen, so a 371px field opened an
701
+ // 800px list starting 421px to its left, outside the drawer that owns
702
+ // the field.
703
+ //
704
+ // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger a
705
+ // a select in a dense grid column — still opens a list
706
+ // wide enough to read, rather than inheriting a width nothing fits in.
707
+ ...(inheritTriggerWidth &&
708
+ !small &&
709
+ triggerWidth > 0 && {
710
+ width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
711
+ maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
712
+ }),
713
+ // Last, so it beats the `max-content` every branch above sets.
714
+ ...(width != null && !small ? { width, maxWidth: width } : null),
715
+ }}
716
+ onClick={(e) => e.stopPropagation()}
717
+ // React synthetic events bubble through portals via the REACT tree, so a
718
+ // keydown inside this popover — or inside a Modal/Alert opened from it,
719
+ // which is a React child even though it portals to document.body — would
720
+ // reach the TRIGGER's ancestors (e.g. a grid cell's Escape-cancels-edit
721
+ // onKeyDown) and let a lower layer act on a higher layer's keys, on
722
+ // keydown, before any keyup layering logic runs. Keyboard sibling of the
723
+ // click curtain above. Keyup deliberately keeps flowing: RN-web Modal
724
+ // closes on a document-level keyup listener, and this popover's own
725
+ // Escape handling is a document capture keyup — neither must be starved.
726
+ onKeyDown={(e) => e.stopPropagation()}
727
+ >
728
+ {small && (
729
+ <View
730
+ style={{
731
+ padding: 8,
732
+ flexDirection: "row",
733
+ justifyContent: "flex-end",
734
+ }}
735
+ >
736
+ <IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
737
+ </View>
738
+ )}
739
+ {header}
740
+ {disableBodyScroll ? (
741
+ <SizeBoundary style={style}>{bodyChildren}</SizeBoundary>
742
+ ) : (
743
+ // FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
744
+ // pads all four sides, so a scroller sitting inside that padding
745
+ // clips its content 12px short of the edge and parks the scrollbar
746
+ // there too — content slides under an invisible margin instead of
747
+ // under the panel's own edge. Pulling out and padding back keeps the
748
+ // text on the exact column the header and footer use while giving the
749
+ // scroll its real edges. `PopoverFooter` already does this dance for
750
+ // its divider; the body needed it for the same reason.
751
+ //
752
+ // Horizontal only: the vertical padding is the gap to the header and
753
+ // footer, which is a gap the reader wants.
754
+ <ScrollView
755
+ style={[SCROLL_BODY, style]}
756
+ contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
757
+ >
758
+ {/* A popover is a box of its own — a few hundred px — so its contents
759
+ size to the panel rather than to whatever region it was opened
760
+ from. Both body paths get it; one of them is not a boundary. */}
761
+ <SizeBoundary>{bodyChildren}</SizeBoundary>
762
+ </ScrollView>
763
+ )}
764
+ {footer}
765
+ </div>
766
+ </Portal>
767
+ </HeadingAltitudeContext.Provider>
759
768
  );
760
769
  }
761
770
 
@@ -8,9 +8,9 @@ export interface PressableRowProps {
8
8
  onPress: () => void;
9
9
  /** The open/selected record — paints the persistent highlight. */
10
10
  selected?: boolean;
11
- /** Part of a multi-select set — paints a persistent blue tint, distinct from
12
- * `selected` (the open record). The hover / open / press wash overrides it, so
13
- * it's the resting state of a ticked row in a bulk-select register. */
11
+ /** Part of a multi-select set — paints the same resting ground `selected` does.
12
+ * What tells a ticked row from an open one is the ticked checkbox, which is
13
+ * unmissable; the press wash still overrides both. */
14
14
  marked?: boolean;
15
15
  /**
16
16
  * - "register" (THE record-list default): a rounded row whose hover/open/`marked`
@@ -89,18 +89,27 @@ export function PressableRow(props: PressableRowProps) {
89
89
  // FOUR states, and they must not collide. Selection and hover painted
90
90
  // the SAME wash, which made "the row whose record is open" and "the row
91
91
  // the pointer happens to be over" indistinguishable — the register's
92
- // one piece of persistent state, erased by a transient one. Hover is
93
- // now neutral and lighter, because it answers "you can press this",
94
- // while selection carries the brand's WASH because it answers "you are
95
- // here" the state the accent exists for. `pressed` stays neutral and
96
- // darkest: a momentary depth cue, not an identity.
97
- // `marked` shares the wash rather than keeping its own literal blue-50:
98
- // that hex was the same colour the wash now resolves to, minus the
99
- // theming, so a branded app painted its ticked rows in a hue it had
100
- // replaced everywhere else. What separates a ticked row from an open
101
- // one is the ticked checkbox, which is unmissable; two washes for two
102
- // orthogonal states could never render anyway on a row that is both.
103
- backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.accent_wash : hovered ? colors.zinc[50] : undefined,
92
+ // one piece of persistent state, erased by a transient one.
93
+ //
94
+ // ONE NEUTRAL STEP is what separates them: zinc-50 hovered, zinc-100
95
+ // selected, zinc-200 pressed. The fix was briefly to give selection a
96
+ // blue ground instead, which read as a brand nobody had chosen on the
97
+ // busiest surface in the product and contradicted `accent`'s own rule
98
+ // that it does not paint interaction chrome, which is exactly what
99
+ // these four states are.
100
+ //
101
+ // THE LITERAL, not `accent_wash`. Reading a selection through the brand
102
+ // token is how the blue got here in the first place: a themed app would
103
+ // tint the row, so "selection is neutral" would be true of the kit and
104
+ // false of every app that set an accent. The token still exists and
105
+ // still means the brand's tint — it is spent on ATTENTION now (a drop
106
+ // target lighting up), never on which record is open.
107
+ //
108
+ // `marked` shares the ground rather than keeping its own blue-50: what
109
+ // separates a ticked row from an open one is the ticked checkbox, which
110
+ // is unmissable, and two grounds for two orthogonal states could never
111
+ // render anyway on a row that is both.
112
+ backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.zinc[100] : hovered ? colors.zinc[50] : undefined,
104
113
  },
105
114
  style,
106
115
  ]}
@@ -7,7 +7,7 @@ import { formatDate } from "./format_date";
7
7
  import { InlineEditView } from "./inline_edit";
8
8
  import { InlineStatic } from "./inline_static";
9
9
  import { Popover, PopoverContent, PopoverFooter, PopoverHeader } from "./popover";
10
- import { DialogSectionHeadingTitle } from "./section_heading";
10
+ import { SectionHeadingTitle } from "./section_heading";
11
11
  import { Text } from "./text";
12
12
  import { TextInputField } from "./text_input_field";
13
13
  import { TextLink } from "./text_link";
@@ -256,19 +256,21 @@ export function ReferenceField(props: ReferenceFieldProps) {
256
256
  </View>
257
257
  }
258
258
  />
259
- {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
260
- is dialog-scale, so the identity takes the ramp's dialog rung
261
- (`DialogSectionHeadingTitle`, ####) with the code as its description
262
- rather than a hand-picked font weight; the facts are `DetailRow`s, which
259
+ {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. The
260
+ identity is a plain `SectionHeadingTitle` with the code as its
261
+ description rather than a hand-picked font weight — a popover is
262
+ dialog-scale, so the ramp puts it on the `####` rung by itself, and
263
+ this call site no longer names the surface it is standing
264
+ on; the facts are `DetailRow`s, which
263
265
  is what label-beside-value IS everywhere else on this page; and the
264
266
  verbs sit in the popover's own pinned footer rather than floating after
265
267
  the last fact.
266
268
 
267
269
  WIDTH COMES FROM THE FIELD (`inheritTriggerWidth`), not a constant. A
268
270
  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
271
+ it was actually the same as was nothing on screen: a wide field opened a
272
+ narrow panel left-aligned under it, stopping short of the edge the
273
+ reader's eye had just travelled to. A
272
274
  popover narrower than the control that opened it reads as belonging to
273
275
  something else. `InlineSelect` — the same shape, an inline field opening
274
276
  a `triggerRef` popover — already inherits its field's width, so the two
@@ -294,7 +296,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
294
296
  is never what scrolls out of view. */}
295
297
  <PopoverContent style={{ maxHeight: 420 }}>
296
298
  <PopoverHeader>
297
- <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
299
+ <SectionHeadingTitle description={code}>{name}</SectionHeadingTitle>
298
300
  </PopoverHeader>
299
301
  <View style={{ gap: 12 }}>
300
302
  {/* ONE geometry for both modes — the table's own 40px band, which is
@@ -1,8 +1,9 @@
1
1
  import { View, type StyleProp, type ViewStyle } from "react-native";
2
- import { Text, type HeadingLevel } from "./text";
2
+ import { Text, type HeadingLevel, type TextSize } from "./text";
3
3
  import { Icon, type IconName } from "./icon";
4
4
  import { InfoPopover } from "./info_popover";
5
5
  import { useLoticsLocale } from "./locale";
6
+ import { useHeadingAltitude, type HeadingAltitude } from "./heading_altitude";
6
7
 
7
8
  // The card-less section header — the bare-canvas sibling of `CardHeader`, built
8
9
  // the same compound way. A title (+ optional leading icon / description) on one
@@ -117,9 +118,15 @@ export interface SubsectionHeadingTitleProps {
117
118
  info?: string;
118
119
  }
119
120
 
120
- /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
121
- * xl section title and above the md/sm body. Grows to push siblings (a
122
- * `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
121
+ /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under a
122
+ * PAGE's xl section title and above the md/sm body. Grows to push siblings (a
123
+ * `SectionHeadingMeta`, a `Badge`, an action) to the right edge.
124
+ *
125
+ * It has one altitude, not two: inside a PANEL the section rung is already
126
+ * the ramp's floor (`####` md), so a subsection there would out-rank its own
127
+ * section. A named group inside a panel's section is `Text size="sm"
128
+ * weight="semibold"` — a panel is one surface, and a second heading level in it
129
+ * is a document the reader is not reading. */
123
130
  export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
124
131
  const { children, description, level = 3, info } = props;
125
132
  const words = useLoticsLocale();
@@ -144,64 +151,6 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
144
151
  );
145
152
  }
146
153
 
147
- export interface DialogSectionHeadingTitleProps {
148
- children: React.ReactNode;
149
- /** Optional muted line under the title, as on `SectionHeadingTitle`. */
150
- description?: string;
151
- /** Leading glyph, as on `SectionHeadingTitle`. */
152
- icon?: IconName;
153
- /** Heading rank. Defaults to 4 — one level under `SubsectionHeadingTitle`'s 3. */
154
- level?: HeadingLevel;
155
- /** An ⓘ popover after the title, as on the two rungs above. */
156
- info?: string;
157
- }
158
-
159
- /**
160
- * The section title for a surface hosted in a DIALOG — ALWAYS `####` (md semibold), the fourth
161
- * rung of the same fixed ladder.
162
- *
163
- * It exists because `DialogHeaderTitle` is `lg`, which is the rung `SubsectionHeadingTitle`
164
- * occupies: a dialog holding a multi-section surface has its own title tying with, or being
165
- * outranked by, its content. The ladder stays ABSOLUTE — this is one more fixed size, never a
166
- * knob on an existing rung. Do not reach for a `size`/`compact` prop on the titles above; the same
167
- * rank rendering at two sizes is exactly what the fixed ramp prevents.
168
- *
169
- * Compose it with the SUBSECTION layout — `Subsection` › `SubsectionHeading` › this, inside a
170
- * `SubsectionStack`. Only the type size steps down; the 12px body gap and the 32px between-group
171
- * beat are already right for a dialog, so duplicating them under a `Dialog*` name would add three
172
- * exports that differ from their siblings in nothing.
173
- *
174
- * <SubsectionStack divided>
175
- * <Subsection>
176
- * <SubsectionHeading>
177
- * <DialogSectionHeadingTitle info="…">Usage</DialogSectionHeadingTitle>
178
- * <SectionHeadingMeta>Aug 2 – Sep 2</SectionHeadingMeta>
179
- * </SubsectionHeading>
180
- * {body}
181
- * </Subsection>
182
- * </SubsectionStack>
183
- */
184
- export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps) {
185
- const { children, description, icon, level = 4, info } = props;
186
- const words = useLoticsLocale();
187
- return (
188
- <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
189
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
190
- {icon ? <Icon name={icon} size={16} /> : null}
191
- <Text level={level} size="md" weight="semibold">
192
- {children}
193
- </Text>
194
- {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
195
- </View>
196
- {description ? (
197
- <Text color="zinc-500" size="sm">
198
- {description}
199
- </Text>
200
- ) : null}
201
- </View>
202
- );
203
- }
204
-
205
154
  export interface SectionHeadingProps {
206
155
  children: React.ReactNode;
207
156
  style?: StyleProp<ViewStyle>;
@@ -219,7 +168,15 @@ export interface SectionHeadingTitleProps {
219
168
  children: React.ReactNode;
220
169
  description?: string;
221
170
  icon?: IconName;
222
- /** Heading rank. Defaults to 2 — typical for page-level section titles. */
171
+ /**
172
+ * Heading rank, when the surface's own outline needs one the altitude does not
173
+ * give it. Leave it unset: the default is the rank of whichever rung the
174
+ * altitude selected (`2` on a page, `4` in a panel), so an outline is
175
+ * correct without anyone thinking about it.
176
+ *
177
+ * It does NOT move the type size — the ramp is absolute, and a rank rendering
178
+ * at two sizes is exactly what a fixed ladder exists to prevent.
179
+ */
223
180
  level?: HeadingLevel;
224
181
  /** Title weight. Defaults to `semibold` — a section title must separate from
225
182
  * the body rows at a glance (medium blends in; it remains as an opt-down for
@@ -230,21 +187,50 @@ export interface SectionHeadingTitleProps {
230
187
  info?: string;
231
188
  }
232
189
 
233
- /** The title block — ALWAYS `##` (xl semibold): the heading ramp is the fixed
234
- * markdown ladder `#` xxl (`RecordSummary`/`PageHeader`) `##` xl (section)
235
- * `###` lg (`SubsectionHeadingTitle`) over the sm body — no per-surface size
236
- * knobs, one outline everywhere. Optional muted `description` line; grows to
237
- * push any sibling actions to the right edge. */
190
+ /**
191
+ * THE SECTION RUNG, per altitude the two fixed points of one ladder, written
192
+ * down once.
193
+ *
194
+ * On a PAGE the section title is `##` (xl), under the `#` xxl identity band.
195
+ * Inside a PANEL it is `####` (md), under the container's `lg` chrome title —
196
+ * the ladder does not gain a knob, it gains a second fixed point, chosen by
197
+ * where the heading IS rather than by what the caller remembered to pass.
198
+ *
199
+ * The glyph steps with the type: a 20px icon beside 16px text is a mark that
200
+ * outweighs the words it introduces.
201
+ */
202
+ const SECTION_RUNG = {
203
+ page: { size: "xl", level: 2, icon: 20 },
204
+ panel: { size: "md", level: 4, icon: 16 },
205
+ } as const satisfies Record<HeadingAltitude, { size: TextSize; level: HeadingLevel; icon: number }>;
206
+
207
+ /** The title block — the heading ramp's SECTION rung, on the fixed markdown
208
+ * ladder `#` xxl (`RecordSummary`/`PageHeader`) → `##` xl (section) → `###` lg
209
+ * (`SubsectionHeadingTitle`) over the sm body. No size knob: which rung it
210
+ * renders is decided by the SURFACE, not the call site — `##` xl on a page,
211
+ * `####` md inside a panel's content region, where the container's own title
212
+ * is already `lg`. A caller never says which; the container publishes it (see
213
+ * `heading_altitude.ts`), so the same heading is correct on a page and in a
214
+ * drawer without being written twice.
215
+ *
216
+ * Inside a panel this rung is the ramp's FLOOR — a pane is one surface,
217
+ * not a document. A named group inside a section there is `Text size="sm"
218
+ * weight="semibold"`, not a `SubsectionHeadingTitle`, which is `lg` at every
219
+ * altitude and would out-rank the section holding it.
220
+ *
221
+ * Optional muted `description` line; grows to push any sibling actions to the
222
+ * right edge. */
238
223
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
239
- const { children, description, icon, level = 2, weight = "semibold", info } = props;
224
+ const { children, description, icon, level, weight = "semibold", info } = props;
240
225
  // Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
241
226
  // render `info` appears or disappears.
242
227
  const words = useLoticsLocale();
228
+ const rung = SECTION_RUNG[useHeadingAltitude()];
243
229
  return (
244
230
  <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
245
231
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
246
- {icon ? <Icon name={icon} size={20} /> : null}
247
- <Text level={level} weight={weight} size="xl">
232
+ {icon ? <Icon name={icon} size={rung.icon} /> : null}
233
+ <Text level={level ?? rung.level} weight={weight} size={rung.size}>
248
234
  {children}
249
235
  </Text>
250
236
  {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
package/src/stepper.tsx CHANGED
@@ -288,9 +288,8 @@ export function Step(props: StepProps) {
288
288
  on the first content row, so there is a `markerTopOffset` band of
289
289
  empty column above it. A single below-the-marker segment cannot
290
290
  reach into the NEXT step's band, so the line broke at every joint
291
- ~4px under each marker (this segment's old top margin) plus the
292
- next one's offset above it. Measured on the record template: 4px
293
- and 3px, at every stage, which reads as dashes rather than a spine.
291
+ a gap under each marker (this segment's old top margin) plus the
292
+ next one's offset above it, which reads as dashes rather than a spine.
294
293
  The upper segment fills that band, so the line is continuous from
295
294
  the first marker to the last. It also replaces the column's old
296
295
  `paddingTop`, which is why the marker lands in the same place. */}
package/src/summary.tsx CHANGED
@@ -231,9 +231,9 @@ const styles = StyleSheet.create({
231
231
  // 12, and it is half of what a page must put BETWEEN this band and its
232
232
  // neighbours. The band's parts are one group — a figure, the shape of what it
233
233
  // counts, and that shape's key — so they have to sit closer to each other than
234
- // the band sits to the register below it. Measured at 14 against a 16px page
235
- // gap, the ratio was 1.14: every block on the screen an equal peer, nothing
236
- // grouped, which is what "no rhythm" is when you measure it.
234
+ // the band sits to the register below it. At near-parity with the page gap
235
+ // every block on the screen is an equal peer, nothing
236
+ // grouped, which is what "no rhythm" means.
237
237
  root: { gap: 12 },
238
238
  header: {
239
239
  flexDirection: "row",
package/src/table.tsx CHANGED
@@ -612,7 +612,10 @@ const styles = StyleSheet.create({
612
612
  // A hairline under the column header anchors the columns; the rows below it are
613
613
  // Divider-separated.
614
614
  headerBandFilled: {
615
- backgroundColor: colors.accent_wash,
615
+ // The literal neutral, not `accent_wash`: this band is a lid on the columns,
616
+ // never a "you are here", and reading it through the brand token made a
617
+ // themed app tint a piece of pure furniture.
618
+ backgroundColor: colors.zinc[100],
616
619
  // The band bleeds to the row wash's width so its edges line up with a hovered
617
620
  // row beneath it; without this the lid is narrower than the rows it caps.
618
621
  marginHorizontal: -ROW_WASH_BLEED,