@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/AGENTS.md +8 -1
- package/MIGRATION.md +61 -5
- package/docs/ai_patterns.md +2 -3
- package/docs/catalog.md +25 -12
- package/docs/composition.md +143 -42
- package/docs/data_entry.md +2 -3
- package/docs/reviewing.md +24 -12
- package/docs/templates.md +1 -1
- package/package.json +2 -2
- package/src/accordion.tsx +2 -2
- package/src/avatar_group.tsx +1 -1
- package/src/avatar_size.ts +2 -3
- package/src/card_select_item.tsx +7 -6
- package/src/color_tokens.ts +22 -6
- package/src/combobox.tsx +11 -1
- package/src/control_surface.ts +22 -16
- package/src/date_stamp.tsx +1 -3
- package/src/detail_row.tsx +4 -5
- package/src/dialog.tsx +13 -6
- package/src/drawer.tsx +18 -4
- package/src/filter_chip.tsx +1 -2
- package/src/finding.tsx +24 -7
- package/src/heading_altitude.ts +65 -0
- package/src/inline_text_input.tsx +4 -5
- package/src/ledger.tsx +3 -3
- package/src/markdown.web.tsx +3 -6
- package/src/matrix.tsx +1 -1
- package/src/modal.tsx +10 -1
- package/src/popover.tsx +151 -142
- package/src/pressable_row.tsx +24 -15
- package/src/reference_field.tsx +11 -9
- package/src/section_heading.tsx +57 -71
- package/src/stepper.tsx +2 -3
- package/src/summary.tsx +3 -3
- package/src/table.tsx +4 -1
- package/src/text_utils.ts +3 -4
- package/src/timeline.tsx +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
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
|
|
156
|
-
//
|
|
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}>
|
package/src/avatar_group.tsx
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
package/src/avatar_size.ts
CHANGED
|
@@ -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
|
|
108
|
-
*
|
|
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
|
package/src/card_select_item.tsx
CHANGED
|
@@ -61,12 +61,13 @@ const styles = StyleSheet.create({
|
|
|
61
61
|
ring: {
|
|
62
62
|
...({ boxShadow: FOCUS_RING } as ViewStyle),
|
|
63
63
|
},
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
-
|
|
70
|
-
|
|
70
|
+
borderColor: colors.zinc[900],
|
|
71
|
+
...({ boxShadow: `0 0 0 1px ${colors.zinc[900]}` } as ViewStyle),
|
|
71
72
|
},
|
|
72
73
|
});
|
package/src/color_tokens.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
|
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}`}
|
package/src/control_surface.ts
CHANGED
|
@@ -190,22 +190,28 @@ export function chipSurfaceStyle(
|
|
|
190
190
|
justifyContent: "center",
|
|
191
191
|
borderRadius: CONTROL_RADIUS,
|
|
192
192
|
borderWidth: 1,
|
|
193
|
-
// A SELECTED chip
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
package/src/date_stamp.tsx
CHANGED
|
@@ -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>
|
package/src/detail_row.tsx
CHANGED
|
@@ -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
|
|
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 —
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
<
|
|
290
|
-
{
|
|
291
|
-
|
|
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
|
|
171
|
-
* panel
|
|
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
|
-
|
|
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 {
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
{
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
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
|
|
58
|
-
*
|
|
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:
|
|
143
|
-
//
|
|
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:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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;
|
package/src/markdown.web.tsx
CHANGED
|
@@ -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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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="
|
|
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
|
-
|
|
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 {
|