@lotics/ui 44.14.0 → 45.3.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.
Files changed (45) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +61 -5
  3. package/docs/ai_patterns.md +2 -3
  4. package/docs/catalog.md +40 -17
  5. package/docs/composition.md +143 -42
  6. package/docs/data_entry.md +15 -8
  7. package/docs/reviewing.md +24 -12
  8. package/docs/templates.md +1 -1
  9. package/package.json +10 -1
  10. package/src/accordion.tsx +2 -2
  11. package/src/avatar_group.tsx +1 -1
  12. package/src/avatar_size.ts +2 -3
  13. package/src/card_select_item.tsx +7 -6
  14. package/src/color_tokens.ts +22 -6
  15. package/src/combobox.tsx +11 -1
  16. package/src/control_surface.ts +22 -16
  17. package/src/date_stamp.tsx +1 -3
  18. package/src/detail_row.tsx +4 -5
  19. package/src/dialog.tsx +13 -6
  20. package/src/drawer.tsx +18 -4
  21. package/src/filter_chip.tsx +1 -2
  22. package/src/finding.tsx +24 -7
  23. package/src/form_markdown_editor.tsx +15 -0
  24. package/src/heading_altitude.ts +65 -0
  25. package/src/inline_markdown.tsx +49 -76
  26. package/src/inline_text_input.tsx +4 -5
  27. package/src/ledger.tsx +3 -3
  28. package/src/locale.tsx +48 -0
  29. package/src/markdown.web.tsx +3 -6
  30. package/src/markdown_editor.css +144 -0
  31. package/src/markdown_editor.tsx +29 -0
  32. package/src/markdown_editor.web.tsx +89 -0
  33. package/src/markdown_editor_props.ts +29 -0
  34. package/src/markdown_toolbar.web.tsx +231 -0
  35. package/src/matrix.tsx +1 -1
  36. package/src/modal.tsx +10 -1
  37. package/src/popover.tsx +151 -142
  38. package/src/pressable_row.tsx +24 -15
  39. package/src/reference_field.tsx +11 -9
  40. package/src/section_heading.tsx +57 -71
  41. package/src/stepper.tsx +2 -3
  42. package/src/summary.tsx +3 -3
  43. package/src/table.tsx +4 -1
  44. package/src/text_utils.ts +3 -4
  45. package/src/timeline.tsx +3 -4
@@ -0,0 +1,231 @@
1
+ import { useCallback } from "react";
2
+ import { View } from "react-native";
3
+ import type { MarkdownEditorHandle } from "@lotics/markdown-editor/editor_view";
4
+ import { markdownSchema } from "@lotics/markdown-editor/schema";
5
+ import type { MarkType, NodeType } from "prosemirror-model";
6
+ import {
7
+ applyLink,
8
+ canApplyLink,
9
+ clearLink,
10
+ insertHorizontalRule,
11
+ insertTable,
12
+ isBlockActive,
13
+ isMarkActive,
14
+ toggleBlockquote,
15
+ toggleBulletList,
16
+ toggleCode,
17
+ toggleCodeBlock,
18
+ toggleEm,
19
+ toggleHeading,
20
+ toggleOrderedList,
21
+ toggleStrong,
22
+ } from "@lotics/markdown-editor/commands";
23
+ import { colors } from "./colors";
24
+ import { IconButton } from "./icon_button";
25
+ import type { IconName } from "./icon";
26
+ import { useLoticsLocale } from "./locale";
27
+
28
+ type EditorCommand = typeof toggleStrong;
29
+
30
+ interface ToolbarButton {
31
+ kind: "button";
32
+ icon: IconName;
33
+ tooltip: string;
34
+ active: boolean;
35
+ onPress: () => void;
36
+ }
37
+
38
+ type ToolbarItem = ToolbarButton | { kind: "divider" };
39
+
40
+ /**
41
+ * The formatting band.
42
+ *
43
+ * It renders whether or not there is an editor behind it yet, and whether or not
44
+ * that editor is enabled — the buttons simply go inert. Presence belongs to the
45
+ * caller (`MarkdownEditor`'s `toolbar`), never to a state that changes while the
46
+ * reader is looking: a band that arrives when the view mounts, or when the field
47
+ * gains focus, moves everything under it at exactly the moment attention is
48
+ * there.
49
+ */
50
+ export function MarkdownToolbar({
51
+ handle,
52
+ disabled,
53
+ }: {
54
+ handle: MarkdownEditorHandle | null;
55
+ disabled?: boolean;
56
+ }) {
57
+ const { markdownToolbar: t } = useLoticsLocale();
58
+ const view = handle?.view ?? null;
59
+ const inert = disabled === true || view === null;
60
+
61
+ const run = useCallback(
62
+ (command: EditorCommand) => {
63
+ if (!view) return;
64
+ command(view.state, view.dispatch, view);
65
+ view.focus();
66
+ },
67
+ [view],
68
+ );
69
+
70
+ const handleLink = useCallback(() => {
71
+ if (!view) return;
72
+ if (isMarkActive(view.state, markdownSchema.marks.link)) {
73
+ run(clearLink);
74
+ return;
75
+ }
76
+ if (!canApplyLink(view.state)) return;
77
+ const href = window.prompt(t.linkUrl)?.trim();
78
+ if (href) run(applyLink(href));
79
+ }, [view, run, t]);
80
+
81
+ const state = view?.state ?? null;
82
+
83
+ // With no document there is nothing to be active IN. Wrapping once beats
84
+ // guarding at each of the thirteen reads below, and keeps the button table a
85
+ // table rather than a column of ternaries.
86
+ const blockActive = (nodeType: NodeType, attrs?: Record<string, unknown>): boolean =>
87
+ state !== null && isBlockActive(state, nodeType, attrs);
88
+ const markActive = (markType: MarkType): boolean =>
89
+ state !== null && isMarkActive(state, markType);
90
+ const { nodes, marks } = markdownSchema;
91
+
92
+ const items: ToolbarItem[] = [
93
+ {
94
+ kind: "button",
95
+ icon: "heading-1",
96
+ tooltip: t.heading1,
97
+ active: blockActive(nodes.heading, { level: 1 }),
98
+ onPress: () => run(toggleHeading(1)),
99
+ },
100
+ {
101
+ kind: "button",
102
+ icon: "heading-2",
103
+ tooltip: t.heading2,
104
+ active: blockActive(nodes.heading, { level: 2 }),
105
+ onPress: () => run(toggleHeading(2)),
106
+ },
107
+ {
108
+ kind: "button",
109
+ icon: "heading-3",
110
+ tooltip: t.heading3,
111
+ active: blockActive(nodes.heading, { level: 3 }),
112
+ onPress: () => run(toggleHeading(3)),
113
+ },
114
+ { kind: "divider" },
115
+ {
116
+ kind: "button",
117
+ icon: "bold",
118
+ tooltip: t.bold,
119
+ active: markActive(marks.strong),
120
+ onPress: () => run(toggleStrong),
121
+ },
122
+ {
123
+ kind: "button",
124
+ icon: "italic",
125
+ tooltip: t.italic,
126
+ active: markActive(marks.em),
127
+ onPress: () => run(toggleEm),
128
+ },
129
+ {
130
+ kind: "button",
131
+ icon: "code",
132
+ tooltip: t.inlineCode,
133
+ active: markActive(marks.code),
134
+ onPress: () => run(toggleCode),
135
+ },
136
+ { kind: "divider" },
137
+ {
138
+ kind: "button",
139
+ icon: "list",
140
+ tooltip: t.bulletList,
141
+ active: blockActive(nodes.bullet_list),
142
+ onPress: () => run(toggleBulletList),
143
+ },
144
+ {
145
+ kind: "button",
146
+ icon: "list-ordered",
147
+ tooltip: t.numberedList,
148
+ active: blockActive(nodes.ordered_list),
149
+ onPress: () => run(toggleOrderedList),
150
+ },
151
+ {
152
+ kind: "button",
153
+ icon: "text-quote",
154
+ tooltip: t.quote,
155
+ active: blockActive(nodes.blockquote),
156
+ onPress: () => run(toggleBlockquote),
157
+ },
158
+ {
159
+ kind: "button",
160
+ icon: "code-xml",
161
+ tooltip: t.codeBlock,
162
+ active: blockActive(nodes.code_block),
163
+ onPress: () => run(toggleCodeBlock),
164
+ },
165
+ { kind: "divider" },
166
+ {
167
+ kind: "button",
168
+ icon: "link-2",
169
+ tooltip: t.link,
170
+ active: markActive(marks.link),
171
+ onPress: handleLink,
172
+ },
173
+ {
174
+ kind: "button",
175
+ icon: "table-2",
176
+ tooltip: t.insertTable,
177
+ active: false,
178
+ onPress: () => run(insertTable),
179
+ },
180
+ {
181
+ kind: "button",
182
+ icon: "minus",
183
+ tooltip: t.divider,
184
+ active: false,
185
+ onPress: () => run(insertHorizontalRule),
186
+ },
187
+ ];
188
+
189
+ return (
190
+ <View
191
+ style={{
192
+ flexDirection: "row",
193
+ flexWrap: "wrap",
194
+ alignItems: "center",
195
+ gap: 2,
196
+ // No rule beneath it. The toolbar sits ABOVE the field rather than
197
+ // inside its frame, so it needs no edge of its own — the field's own
198
+ // border is the boundary, and a second line 4px above it was two
199
+ // boundaries for one object.
200
+ paddingBottom: 6,
201
+ }}
202
+ >
203
+ {items.map((item, index) =>
204
+ item.kind === "divider" ? (
205
+ <View
206
+ key={`divider-${index}`}
207
+ style={{ width: 1, height: 22, marginHorizontal: 6, backgroundColor: colors.border }}
208
+ />
209
+ ) : (
210
+ <IconButton
211
+ key={item.icon}
212
+ // OUTSIDE the field, these are standalone controls rather than
213
+ // furniture inside one — and a standalone control is the band's
214
+ // height. Inside the frame `md` was right; on their own they read
215
+ // as undersized next to every other control on the page.
216
+ size="lg"
217
+ icon={item.icon}
218
+ tooltip={item.tooltip}
219
+ onPress={item.onPress}
220
+ disabled={inert}
221
+ iconColor={item.active ? colors.zinc[900] : colors.zinc[600]}
222
+ style={{
223
+ borderRadius: 8,
224
+ backgroundColor: item.active ? colors.zinc[200] : "transparent",
225
+ }}
226
+ />
227
+ ),
228
+ )}
229
+ </View>
230
+ );
231
+ }
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 {
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
  ]}