@lotics/ui 32.0.0 → 34.0.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/MIGRATION.md CHANGED
@@ -4,6 +4,91 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 34.0.0 — one avatar rung fits a control band
8
+
9
+ **`AVATAR_PX.md` is 28, not 36.** Nothing to change at a call site — `md` is still
10
+ the default and still shows two initials — but every `Avatar`, `MemberChip` and
11
+ `GroupAvatar` on that rung renders 8px smaller (its initials drop a type rung with
12
+ it, to hold the same share of the circle), and the controls holding them get
13
+ SHORTER, which is the point.
14
+
15
+ `CONTROL_HEIGHT` is a promise: a toolbar row, a record's value column and a
16
+ picker's option list read as one band because every control on them is 40. TEXT
17
+ keeps that promise for free — a line is ~20, so `minHeight` governs and a
18
+ surface's vertical padding never binds. That is exactly why each 40px surface had
19
+ quietly picked its own inset (`Select`'s trigger 4, the inline editor 6,
20
+ `InlineSlot` 8) with no number to pick it from, and nothing on a resting screen
21
+ ever showed the disagreement.
22
+
23
+ Putting a 36px avatar in one broke all three at once. The same member chip
24
+ measured **46** in a `Select` trigger, **50** in an inline editor and **54** in an
25
+ `InlineSlot` — a record whose value column is the only thing telling a reader
26
+ these fields belong to one record, with the member row 10px off every other row
27
+ in it.
28
+
29
+ So the budget is stated once and the surfaces derive from it:
30
+
31
+ - **`CONTROL_CONTENT_HEIGHT`** (28) — the tallest fixed-height node a 40px band
32
+ seats, 6 per side of air. Not a fresh number: it is what `InlineButton` and a
33
+ `md` `IconButton` had already settled on for sitting inside a field. An avatar
34
+ is not a special kind of child, so it gets the same height they do.
35
+ - **`CONTROL_PADDING_V`** (5) — derived, and the 1px border counts inside the 6
36
+ per side. Reading the padding and forgetting the border is how the drift
37
+ started.
38
+
39
+ Both from `@lotics/ui/control_surface`. **If you build your own 40px control, pad
40
+ by `CONTROL_PADDING_V` and keep fixed-height children at or under
41
+ `CONTROL_CONTENT_HEIGHT`** — anything taller belongs in a row that sets its own
42
+ height, not in a control.
43
+
44
+ Two knock-on effects worth looking at once:
45
+
46
+ - A **multi-line control at rest** (a rich select showing title + description) is
47
+ ~2px shorter, since its padding came off the same constant.
48
+ - An `InlineSlot`/`InlineStatic` value now actually matches the editor beside it.
49
+ It always claimed to; it was 2px more padded, invisible at one line.
50
+
51
+ ## 33.0.0 — `DrawerScrollArea`
52
+
53
+ A `Drawer` gets the content primitive its siblings already had, named to match
54
+ `DialogScrollArea`. **Wrap your drawer body in it and delete the padding you were
55
+ applying:**
56
+
57
+ ```diff
58
+ <Drawer open onOpenChange={setOpen} title={name}>
59
+ - <View style={{ flex: 1, padding: 24, gap: 12 }}>
60
+ + <DrawerScrollArea>
61
+ + <View style={{ gap: 12 }}>
62
+
63
+ </View>
64
+ </Drawer>
65
+ ```
66
+
67
+ **Why a component and not a default.** The gutter is a contract: content has to
68
+ land on the same left edge as the header's title and the footer's actions, and
69
+ only the panel knows that number. Left to callers it was answered three ways
70
+ across the templates and the live apps — `24` six times (4px past the drawer's own
71
+ title), `20` three times, and in one shipped app not at all, which put a comment
72
+ thread flush against the panel edge while its title was inset 20.
73
+
74
+ The `Drawer`'s own slot stays BARE, which is how `Modal` and `Dialog` already
75
+ work. A drawer that padded its slot would be the one overlay of the three that
76
+ does, and would force every full-bleed child to cancel it with a negative margin.
77
+
78
+ **Two bodies do NOT wrap in it**, and both take the bare slot instead:
79
+
80
+ - **Full-bleed** — a band spanning the panel, a record screen carrying its own
81
+ gutters. Nothing to do; the slot already reaches the edge.
82
+ - **Pinned + scrolling** — a heading, count or summary held above a list that
83
+ scrolls under it. Put `DRAWER_GUTTER` on the pinned part and on the scroller's
84
+ `contentContainerStyle`. **Do not** wrap the pair in a padded box: a box with
85
+ padding insets the scroller's VIEWPORT, so the list ends short of the panel with
86
+ dead space beneath it and the last row clipped. Measured: a 400px panel ends its
87
+ scroll at 380.
88
+
89
+ Import `DRAWER_GUTTER` in both cases; a hardcoded `20` stops matching the day it
90
+ moves.
91
+
7
92
  ## 32.0.0 — `Table`'s `align` prop is gone; rows are centred
8
93
 
9
94
  `align?: "center" | "top"` and the `TableAlign` type are removed. `center` was the
package/docs/catalog.md CHANGED
@@ -76,12 +76,20 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
76
76
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
77
77
  renders these per option.)
78
78
 
79
- **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 36 / 48 / 72
79
+ **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 48 / 72
80
80
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
81
81
  The number was removed because the same concept had drifted to eleven different values across
82
82
  the product and its apps, so one person looked different on every screen. Pick by density:
83
- `sm` for a dense row or inline cell, `md` for a register or list (the default), `lg` for a
84
- prominent row or the account menu, `xl` for a profile's own header.
83
+ `sm` for a dense row or inline cell, `md` for a control band, register or list (the default),
84
+ `lg` for a prominent row or the account menu, `xl` for a profile's own header.
85
+
86
+ **`md` is `CONTROL_CONTENT_HEIGHT`** (28) — the largest rung a 40px control band seats without
87
+ growing, and the height `InlineButton` and a `md` `IconButton` already used to sit inside a
88
+ field. An avatar is the tallest thing in whatever row holds it, so a rung that overshoots
89
+ doesn't look slightly big, it RESIZES the control: at the old 36 the same member chip measured
90
+ 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`, because each
91
+ surface had picked its own inset with no number to pick it from. Put nothing taller than `md`
92
+ in a control — a bigger avatar belongs in a row that sets its own height.
85
93
 
86
94
  **`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
87
95
  That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
@@ -411,7 +419,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
411
419
  hand-rolled plain DOM/CSS (nothing injects them automatically; `@lotics/ui` components
412
420
  don't need them).
413
421
  - **`spacing`** — the `SPACE` scale + `SpaceToken`.
414
- - **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
422
+ - **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_CONTENT_HEIGHT` (28 — the tallest
423
+ FIXED-height node a 40px band seats, 6 per side of air; what `InlineButton` and a `md`
424
+ `IconButton` already used, now named so an avatar or any other child lands on it too. Text
425
+ never needs it because `minHeight` governs a single line, which is exactly why three surfaces
426
+ could each pick a different inset and only disagree once an avatar went in),
427
+ `CONTROL_PADDING_V` (5 — derived from the pair, BORDER INCLUDED in the 6 per side; every 40px
428
+ surface pads by it instead of a number of its own), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
415
429
  (9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
416
430
  up with a control's WORDS rather than its box carries it — a caption under an inline editor,
417
431
  a note under a figure — rather than a hand-measured 8),
@@ -1157,9 +1171,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1157
1171
  - **`dialog`** — `Dialog` (+ `DialogHeader`/`DialogHeaderTitle`/`DialogFooter`, `useDialog`,
1158
1172
  `useDialogNavigation`): the centered card over a scrim; BAKES a screen router in
1159
1173
  (`<Dialog><Screen route="">…` — see `screen_router`).
1160
- - **`drawer`** — `Drawer` + `DrawerFooter`: the docked side panel with scrim; the register
1161
- row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
1162
- exceptional panel; full-width on small screens.
1174
+ - **`drawer`** — `Drawer` + `DrawerScrollArea` + `DrawerFooter`: the docked side panel with scrim; the
1175
+ register row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
1176
+ exceptional panel; full-width on small screens. `DrawerScrollArea` is the guttered content
1177
+ region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. It always
1178
+ scrolls: a padded box around a `flex:1` scroller insets that scroller's viewport, ending the
1179
+ list short of the panel with the last row clipped. Two bodies skip it and take the `Drawer`'s
1180
+ bare slot: a FULL-BLEED one (a band spanning the panel, a record screen with its own gutters),
1181
+ and a PINNED one (a heading or summary above a scrolling list), which puts the exported
1182
+ `DRAWER_GUTTER` on the pinned part and on the scroller's `contentContainerStyle` rather than
1183
+ copying `20`.
1163
1184
  - **`modal`** — `Modal` + `ModalHeader` + `ModalBody` + `ModalFooter` — the full-bleed,
1164
1185
  edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
1165
1186
  (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
@@ -10,9 +10,18 @@ them produces off-system UI.
10
10
 
11
11
  ## Canvas & content column
12
12
 
13
- - **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28` (a `Drawer`'s scroll body
14
- uses 24). Cards separate from the canvas by their own border + shadow, not by a tinted
15
- background.
13
+ - **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28`. Cards separate from the
14
+ canvas by their own border + shadow, not by a tinted background.
15
+ - **An overlay's gutter belongs to a CONTENT primitive, never to the caller.** `ModalBody`,
16
+ `DialogScrollArea` and `DrawerScrollArea` each carry their surface's inset, so content lands on
17
+ the same left edge as the title above it and the actions below it. Never hand-pad inside one,
18
+ and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
19
+ only the surface knows which it is.
20
+ - **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
21
+ ends short of the surface with dead space beneath it and its last row clipped — measured, a
22
+ 400px panel ends its scroll at 380. A pinned heading above a scrolling list therefore takes the
23
+ overlay's BARE slot and puts the gutter on the pinned part and on the scroller's
24
+ `contentContainerStyle`. Same for a full-bleed band, which wants no gutter at all.
16
25
  - **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
17
26
  sized to the job, with `gap: 16` between top-level bands/cards:
18
27
 
@@ -508,7 +517,19 @@ shape.
508
517
 
509
518
  ## One view-control vocabulary
510
519
 
511
- All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping band:
520
+ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping band.
521
+
522
+ **A 40px band affords 28 (`CONTROL_CONTENT_HEIGHT`), and a surface pads by
523
+ `CONTROL_PADDING_V` rather than a number of its own.** The height only holds for free when the
524
+ content is TEXT: a line is ~20, so `minHeight` governs and the padding never binds — which is
525
+ why a surface can pick any inset it likes and nothing on a resting screen ever contradicts it.
526
+ The moment something with a FIXED height goes in — an avatar, a swatch, a thumbnail — that
527
+ inset decides the control's whole height, and three surfaces that had each chosen their own
528
+ (4, 6, 8) rendered one member chip at 46, 50 and 54. So: a fixed-height child is sized against
529
+ the band's budget, never against how it looks alone, and a surface that needs more room than
530
+ the budget is not a control. The budget is 28 because that is what `InlineButton` and a `md`
531
+ `IconButton` were already using inside a field — a control's children must not differ in height
532
+ by what KIND of thing they are, so an avatar takes the same number rather than a fourth one.
512
533
 
513
534
  - **`SearchInput`** is THE search box — a white `TextInputField` preset (the shared
514
535
  `CONTROL_RADIUS` like every other input, a thin border, the leading search glyph + a clear ✕).
@@ -9,7 +9,7 @@ import { Badge } from "@lotics/ui/badge";
9
9
  import { Button } from "@lotics/ui/button";
10
10
  import { Card } from "@lotics/ui/card";
11
11
  import { Divider } from "@lotics/ui/divider";
12
- import { Drawer } from "@lotics/ui/drawer";
12
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
13
13
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
14
14
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
15
15
  import { KPIStrip } from "@lotics/ui/kpi_strip";
@@ -205,38 +205,40 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
205
205
  const tt = TRANG_THAI[nv.trangThai];
206
206
  return (
207
207
  <>
208
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
209
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
210
- <Avatar name={nv.ten} size="lg" />
211
- <View style={{ flex: 1, gap: 2 }}>
212
- <Text size="sm" color="muted">{nv.chucVu}</Text>
213
- </View>
214
- <Badge label={badgeLabel(nv)} color={tt.color} />
215
- </View>
216
- <View style={{ gap: 6 }}>
217
- <KVRow label="Clock-in" value={nv.vao ?? "Not clocked"} />
218
- <KVRow label="Clock-out" value={nv.ra ?? "Not clocked"} />
219
- <KVRow label="Hours today" value={nv.tongGio ?? "—"} />
220
- </View>
221
- <Divider />
222
- <View style={{ gap: 10 }}>
223
- <View style={{ flexDirection: "row", alignItems: "center" }}>
224
- <View style={{ flex: 1 }}>
225
- <Text size="xs" color="muted" weight="medium">This week</Text>
208
+ <DrawerScrollArea>
209
+ <View style={{ gap: 20 }}>
210
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
211
+ <Avatar name={nv.ten} size="lg" />
212
+ <View style={{ flex: 1, gap: 2 }}>
213
+ <Text size="sm" color="muted">{nv.chucVu}</Text>
226
214
  </View>
227
- <Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
215
+ <Badge label={badgeLabel(nv)} color={tt.color} />
228
216
  </View>
229
217
  <View style={{ gap: 6 }}>
230
- {nv.tuan.map((ngay, i) => (
231
- <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
232
- <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
233
- <DayCell ngay={ngay} />
234
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
218
+ <KVRow label="Clock-in" value={nv.vao ?? "Not clocked"} />
219
+ <KVRow label="Clock-out" value={nv.ra ?? "Not clocked"} />
220
+ <KVRow label="Hours today" value={nv.tongGio ?? "—"} />
221
+ </View>
222
+ <Divider />
223
+ <View style={{ gap: 10 }}>
224
+ <View style={{ flexDirection: "row", alignItems: "center" }}>
225
+ <View style={{ flex: 1 }}>
226
+ <Text size="xs" color="muted" weight="medium">This week</Text>
235
227
  </View>
236
- ))}
228
+ <Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
229
+ </View>
230
+ <View style={{ gap: 6 }}>
231
+ {nv.tuan.map((ngay, i) => (
232
+ <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
233
+ <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
234
+ <DayCell ngay={ngay} />
235
+ <Text size="sm">{NGAY_CONG[ngay].label}</Text>
236
+ </View>
237
+ ))}
238
+ </View>
237
239
  </View>
238
240
  </View>
239
- </ScrollView>
241
+ </DrawerScrollArea>
240
242
  {/* pinned footer — the workspace's one primary fix action */}
241
243
  <View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", gap: 12 }}>
242
244
  <Text size="xs" color="muted" style={{ flex: 1 }}>Missed punches get fixed here — every change is audited.</Text>
@@ -7,7 +7,7 @@ import { Badge } from "@lotics/ui/badge";
7
7
  import { Button } from "@lotics/ui/button";
8
8
  import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
9
9
  import { Divider } from "@lotics/ui/divider";
10
- import { Drawer } from "@lotics/ui/drawer";
10
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
11
11
  import { ListItem } from "@lotics/ui/list_item";
12
12
  import {
13
13
  CalendarView,
@@ -154,17 +154,19 @@ function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
154
154
  ];
155
155
  return (
156
156
  <>
157
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 10 }}>
158
- {rows.map(([label, value], i) => (
159
- <View key={label} style={{ gap: 10 }}>
160
- {i > 0 ? <Divider /> : null}
161
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
162
- <Text size="sm" color="muted" style={{ width: 104 }}>{label}</Text>
163
- {value}
157
+ <DrawerScrollArea>
158
+ <View style={{ gap: 10 }}>
159
+ {rows.map(([label, value], i) => (
160
+ <View key={label} style={{ gap: 10 }}>
161
+ {i > 0 ? <Divider /> : null}
162
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
163
+ <Text size="sm" color="muted" style={{ width: 104 }}>{label}</Text>
164
+ {value}
165
+ </View>
164
166
  </View>
165
- </View>
166
- ))}
167
- </ScrollView>
167
+ ))}
168
+ </View>
169
+ </DrawerScrollArea>
168
170
  <View
169
171
  style={{
170
172
  borderTopWidth: 1,
@@ -26,7 +26,7 @@ import {
26
26
  } from "@lotics/ui/date_range_filter_field";
27
27
  import { type DateFilterValue } from "@lotics/ui/date_filter";
28
28
  import { Divider } from "@lotics/ui/divider";
29
- import { Drawer } from "@lotics/ui/drawer";
29
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
30
30
  import { PressableRow } from "@lotics/ui/pressable_row";
31
31
  import { type IconName } from "@lotics/ui/icon";
32
32
  import { KPIStrip } from "@lotics/ui/kpi_strip";
@@ -530,25 +530,27 @@ export function TplDashboard() {
530
530
  onNext={openRec.idx < recAlert.records.length - 1 ? () => setOpenRec({ alertKey: openRec.alertKey, idx: openRec.idx + 1 }) : undefined}
531
531
  position={`${openRec.idx + 1}/${recAlert.records.length}`}
532
532
  >
533
- <View key={rec.id} style={{ flex: 1, padding: 24, gap: 12 }}>
534
- <Badge label={recAlert.label} color={recAlert.tone === "warning" ? "amber" : "red"} />
535
- <View style={{ gap: 10, paddingTop: 4 }}>
536
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
537
- <Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
538
- <Text size="sm" weight="medium">{rec.name}</Text>
539
- </View>
540
- <Divider />
541
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
542
- <Text size="sm" color="muted" style={{ flex: 1 }}>Amount / due</Text>
543
- <Text size="sm" tabular>{rec.value}</Text>
544
- </View>
545
- <Divider />
546
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
547
- <Text size="sm" color="muted" style={{ flex: 1 }}>Status</Text>
548
- <Text size="sm" color={rec.noteTone} tabular>{rec.note}</Text>
533
+ <DrawerScrollArea key={rec.id}>
534
+ <View style={{ gap: 12 }}>
535
+ <Badge label={recAlert.label} color={recAlert.tone === "warning" ? "amber" : "red"} />
536
+ <View style={{ gap: 10, paddingTop: 4 }}>
537
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
538
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
539
+ <Text size="sm" weight="medium">{rec.name}</Text>
540
+ </View>
541
+ <Divider />
542
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
543
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Amount / due</Text>
544
+ <Text size="sm" tabular>{rec.value}</Text>
545
+ </View>
546
+ <Divider />
547
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
548
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Status</Text>
549
+ <Text size="sm" color={rec.noteTone} tabular>{rec.note}</Text>
550
+ </View>
549
551
  </View>
550
552
  </View>
551
- </View>
553
+ </DrawerScrollArea>
552
554
  <View
553
555
  style={{
554
556
  borderTopWidth: 1,
@@ -10,7 +10,7 @@ import { Button } from "@lotics/ui/button";
10
10
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
11
11
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
12
12
  import { Divider } from "@lotics/ui/divider";
13
- import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
13
+ import { Drawer, DrawerScrollArea, DrawerFooter } from "@lotics/ui/drawer";
14
14
  import { EmptyState } from "@lotics/ui/empty_state";
15
15
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
16
16
  import { FormField } from "@lotics/ui/form_field";
@@ -350,37 +350,39 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
350
350
  if (!r) return null;
351
351
  return (
352
352
  <>
353
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
354
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
355
- <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
356
- <Text size="sm" color="muted">{`Linked record: ${r.khach}`}</Text>
353
+ <DrawerScrollArea>
354
+ <View style={{ gap: 16 }}>
355
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
356
+ <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
357
+ <Text size="sm" color="muted">{`Linked record: ${r.khach}`}</Text>
358
+ </View>
359
+ <DetailTable labelWidth={120}>
360
+ <DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
361
+ <DetailRow label="Phone"><InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" /></DetailRow>
362
+ <DetailRow label="Assignee">
363
+ <InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
364
+ </DetailRow>
365
+ <DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
366
+ <DetailRow label="Fee">
367
+ <InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
368
+ </DetailRow>
369
+ <DetailRow label="Collected">
370
+ <InlineSelect
371
+ value={collected}
372
+ onSave={persist(setCollected)}
373
+ options={[{ value: "yes", label: "Collected" }, { value: "no", label: "Outstanding" }]}
374
+ accessibilityLabel="Collected"
375
+ renderOptionContent={(o) => (
376
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
377
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: o.value === "yes" ? colors.emerald[500] : colors.amber[500] }} />
378
+ <Text size="sm">{o.label}</Text>
379
+ </View>
380
+ )}
381
+ />
382
+ </DetailRow>
383
+ </DetailTable>
357
384
  </View>
358
- <DetailTable labelWidth={120}>
359
- <DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
360
- <DetailRow label="Phone"><InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" /></DetailRow>
361
- <DetailRow label="Assignee">
362
- <InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
363
- </DetailRow>
364
- <DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
365
- <DetailRow label="Fee">
366
- <InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
367
- </DetailRow>
368
- <DetailRow label="Collected">
369
- <InlineSelect
370
- value={collected}
371
- onSave={persist(setCollected)}
372
- options={[{ value: "yes", label: "Collected" }, { value: "no", label: "Outstanding" }]}
373
- accessibilityLabel="Collected"
374
- renderOptionContent={(o) => (
375
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
376
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: o.value === "yes" ? colors.emerald[500] : colors.amber[500] }} />
377
- <Text size="sm">{o.label}</Text>
378
- </View>
379
- )}
380
- />
381
- </DetailRow>
382
- </DetailTable>
383
- </ScrollView>
385
+ </DrawerScrollArea>
384
386
  {/* the pushed record's OWN footer — different CTAs from the root case */}
385
387
  <DrawerFooter>
386
388
  <Text size="xs" color="muted" style={{ flex: 1 }}>Changes save in place on this linked record.</Text>
@@ -5,7 +5,7 @@ import { colors } from "@lotics/ui/colors";
5
5
  import { Badge } from "@lotics/ui/badge";
6
6
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
7
  import { Divider } from "@lotics/ui/divider";
8
- import { Drawer } from "@lotics/ui/drawer";
8
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
9
9
  import { EmptyState } from "@lotics/ui/empty_state";
10
10
  import { KPIStrip } from "@lotics/ui/kpi_strip";
11
11
  import { Chip } from "@lotics/ui/chip";
@@ -230,18 +230,20 @@ export function TplPivot() {
230
230
  onNext={openIdx < Math.min(items.length, 10) - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
231
231
  position={`${openIdx + 1}/${Math.min(items.length, 10)}`}
232
232
  >
233
- <View key={open.code} style={{ flex: 1, padding: 24, gap: 12 }}>
234
- <Badge variant="dot" label={STATUS[open.status].label} color={STATUS[open.status].color} />
235
- <View style={{ gap: 6, paddingTop: 4 }}>
236
- <KVRow label="Item"><Text size="sm">{open.name}</Text></KVRow>
237
- <Divider />
238
- <KVRow label="Warehouse"><Text size="sm">{whOf(open.warehouse).label}</Text></KVRow>
239
- <Divider />
240
- <KVRow label="Category"><Text size="sm">{catOf(open.category).label}</Text></KVRow>
241
- <Divider />
242
- <KVRow label="Units on hand"><Text size="sm" tabular>{count(open.units)}</Text></KVRow>
233
+ <DrawerScrollArea key={open.code}>
234
+ <View style={{ gap: 12 }}>
235
+ <Badge variant="dot" label={STATUS[open.status].label} color={STATUS[open.status].color} />
236
+ <View style={{ gap: 6, paddingTop: 4 }}>
237
+ <KVRow label="Item"><Text size="sm">{open.name}</Text></KVRow>
238
+ <Divider />
239
+ <KVRow label="Warehouse"><Text size="sm">{whOf(open.warehouse).label}</Text></KVRow>
240
+ <Divider />
241
+ <KVRow label="Category"><Text size="sm">{catOf(open.category).label}</Text></KVRow>
242
+ <Divider />
243
+ <KVRow label="Units on hand"><Text size="sm" tabular>{count(open.units)}</Text></KVRow>
244
+ </View>
243
245
  </View>
244
- </View>
246
+ </DrawerScrollArea>
245
247
  </Drawer>
246
248
  ) : null}
247
249
  </ScrollView>
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
+ import { DRAWER_GUTTER } from "@lotics/ui/drawer";
5
6
  import { Button } from "@lotics/ui/button";
6
7
  import { BackButton } from "@lotics/ui/back_button";
7
8
  import { Divider } from "@lotics/ui/divider";
@@ -2011,7 +2012,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2011
2012
  // no box, so its ink is what the eye lines up — 18 + 10 = 28. Set the bar,
2012
2013
  // not a negative margin on the button: the gutter belongs to the bar, and
2013
2014
  // one number here cannot drift out of step with itself.
2014
- <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? 20 : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
2015
+ <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? DRAWER_GUTTER : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
2015
2016
  {/* narrow keeps the standard too — the bare back glyph beside the
2016
2017
  section bar (the rail that would carry it is collapsed). A DRAWER
2017
2018
  already has one in its own header; two would be two exits. */}
@@ -2066,7 +2067,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2066
2067
  {/* The gutter is CHROME's, not the record's: 28 is the page canvas, 20 is what
2067
2068
  a `Drawer` pads its header with — so in a drawer the content lines up with
2068
2069
  the record NAME in the header above it instead of sitting 8px inside it. */}
2069
- <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ? 20 : 28, paddingBottom: 96 }}>
2070
+ <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ? DRAWER_GUTTER : 28, paddingBottom: 96 }}>
2070
2071
  {/* The centred group: a gutter RESERVED for the rail, the reading column,
2071
2072
  and an equal empty gutter that balances it — so the column's centre is
2072
2073
  the window's centre. Both gutters live INSIDE the scroller, so they
@@ -6,7 +6,7 @@ import { Accordion, AccordionContent, AccordionHeader, AccordionTitle } from "@l
6
6
  import { Button } from "@lotics/ui/button";
7
7
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
8
8
  import { Divider } from "@lotics/ui/divider";
9
- import { Drawer } from "@lotics/ui/drawer";
9
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
10
10
  import { KPIStrip } from "@lotics/ui/kpi_strip";
11
11
  import { PressableHighlight } from "@lotics/ui/pressable_highlight";
12
12
  import { Sparkline } from "@lotics/ui/sparkline";
@@ -255,30 +255,32 @@ export function TplRollup() {
255
255
  onNext={open.idx < open.site.lanes.length - 1 ? () => setOpen({ site: open.site, idx: open.idx + 1 }) : undefined}
256
256
  position={`${open.idx + 1}/${open.site.lanes.length}`}
257
257
  >
258
- <View key={openLane.id} style={{ flex: 1, padding: 24, gap: 16 }}>
259
- <View style={{ gap: 6 }}>
260
- <KVRow label="Route"><Text size="sm">{`${open.site.label} ${openLane.dest}`}</Text></KVRow>
261
- <Divider />
262
- <KVRow label="Volume this month"><Text size="sm" tabular>{openLane.volume.toLocaleString("en-US")}</Text></KVRow>
263
- <Divider />
264
- <KVRow label="On-time">
265
- <Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
266
- {`${openLane.ontime}% (target ${ONTIME_TARGET}%)`}
267
- </Text>
268
- </KVRow>
269
- <Divider />
270
- <KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
258
+ <DrawerScrollArea key={openLane.id}>
259
+ <View style={{ gap: 16 }}>
260
+ <View style={{ gap: 6 }}>
261
+ <KVRow label="Route"><Text size="sm">{`${open.site.label} → ${openLane.dest}`}</Text></KVRow>
262
+ <Divider />
263
+ <KVRow label="Volume this month"><Text size="sm" tabular>{openLane.volume.toLocaleString("en-US")}</Text></KVRow>
264
+ <Divider />
265
+ <KVRow label="On-time">
266
+ <Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
267
+ {`${openLane.ontime}% (target ${ONTIME_TARGET}%)`}
268
+ </Text>
269
+ </KVRow>
270
+ <Divider />
271
+ <KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
272
+ </View>
273
+ <View style={{ gap: 8 }}>
274
+ <Text size="xs" color="muted" weight="medium">Volume (last 12 weeks)</Text>
275
+ <Sparkline
276
+ data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
277
+ width={200}
278
+ height={36}
279
+ color={openLane.ontime < ONTIME_TARGET ? solid("amber") : solid("blue")}
280
+ />
281
+ </View>
271
282
  </View>
272
- <View style={{ gap: 8 }}>
273
- <Text size="xs" color="muted" weight="medium">Volume (last 12 weeks)</Text>
274
- <Sparkline
275
- data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
276
- width={200}
277
- height={36}
278
- color={openLane.ontime < ONTIME_TARGET ? solid("amber") : solid("blue")}
279
- />
280
- </View>
281
- </View>
283
+ </DrawerScrollArea>
282
284
  <View
283
285
  style={{
284
286
  borderTopWidth: 1,
@@ -9,7 +9,7 @@ import { Button } from "@lotics/ui/button";
9
9
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
10
10
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
11
11
  import { Divider } from "@lotics/ui/divider";
12
- import { Drawer } from "@lotics/ui/drawer";
12
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
13
13
  import { EmptyState } from "@lotics/ui/empty_state";
14
14
  import { KPIStrip } from "@lotics/ui/kpi_strip";
15
15
  import { MenuListItem } from "@lotics/ui/menu_list_item";
@@ -328,58 +328,60 @@ export function TplShifts() {
328
328
  onNext={openIdx < allSlots.length - 1 ? () => setOpenSlot(slotId(allSlots[openIdx + 1].day, allSlots[openIdx + 1].shift)) : undefined}
329
329
  position={`${openIdx + 1}/${allSlots.length}`}
330
330
  >
331
- <ScrollView key={openSlot} style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
332
- <ProgressBar
333
- title="Coverage"
334
- value={openAssigned.length}
335
- max={openRequired}
336
- format="fraction"
337
- color={solid("amber")}
338
- completeColor={solid("emerald")}
339
- />
340
- <View style={{ gap: 8 }}>
341
- <Text size="sm" weight="semibold">On this shift</Text>
342
- {openAssigned.length === 0 ? (
343
- <Text size="sm" color="muted">Nobody yet — accept a signup or add a member below.</Text>
344
- ) : (
345
- openAssigned.map((m) => (
346
- <View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
347
- <Avatar name={m} size="md" />
348
- <Text size="sm" style={{ flex: 1 }}>{m}</Text>
349
- <Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
350
- <ActionMenu
351
- accessibilityLabel={`Actions for ${m}`}
352
- items={[{ key: "remove", label: "Remove from shift", icon: "ban", danger: true, onPress: () => unassign(openSlot, m) }]}
353
- />
354
- </View>
355
- ))
356
- )}
357
- </View>
358
- {openSignups.length > 0 ? (
331
+ <DrawerScrollArea key={openSlot}>
332
+ <View style={{ gap: 16 }}>
333
+ <ProgressBar
334
+ title="Coverage"
335
+ value={openAssigned.length}
336
+ max={openRequired}
337
+ format="fraction"
338
+ color={solid("amber")}
339
+ completeColor={solid("emerald")}
340
+ />
359
341
  <View style={{ gap: 8 }}>
360
- <Text size="sm" weight="semibold">Asked for this shift</Text>
361
- {openSignups.map((su) => {
362
- const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
363
- return (
364
- <View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
365
- <Avatar name={su.member} size="md" />
366
- <Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
367
- <Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
368
- {hoursAfter > WEEK_LIMIT ? (
369
- <OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
370
- ) : (
371
- <Button title="Accept" color="secondary" onPress={() => accept(su)} />
372
- )}
342
+ <Text size="sm" weight="semibold">On this shift</Text>
343
+ {openAssigned.length === 0 ? (
344
+ <Text size="sm" color="muted">Nobody yet — accept a signup or add a member below.</Text>
345
+ ) : (
346
+ openAssigned.map((m) => (
347
+ <View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
348
+ <Avatar name={m} size="md" />
349
+ <Text size="sm" style={{ flex: 1 }}>{m}</Text>
350
+ <Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
351
+ <ActionMenu
352
+ accessibilityLabel={`Actions for ${m}`}
353
+ items={[{ key: "remove", label: "Remove from shift", icon: "ban", danger: true, onPress: () => unassign(openSlot, m) }]}
354
+ />
373
355
  </View>
374
- );
375
- })}
356
+ ))
357
+ )}
376
358
  </View>
377
- ) : null}
378
- <Divider />
379
- <KVRow label="Required">
380
- <Text size="sm" tabular>{`${openRequired} members`}</Text>
381
- </KVRow>
382
- </ScrollView>
359
+ {openSignups.length > 0 ? (
360
+ <View style={{ gap: 8 }}>
361
+ <Text size="sm" weight="semibold">Asked for this shift</Text>
362
+ {openSignups.map((su) => {
363
+ const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
364
+ return (
365
+ <View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
366
+ <Avatar name={su.member} size="md" />
367
+ <Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
368
+ <Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
369
+ {hoursAfter > WEEK_LIMIT ? (
370
+ <OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
371
+ ) : (
372
+ <Button title="Accept" color="secondary" onPress={() => accept(su)} />
373
+ )}
374
+ </View>
375
+ );
376
+ })}
377
+ </View>
378
+ ) : null}
379
+ <Divider />
380
+ <KVRow label="Required">
381
+ <Text size="sm" tabular>{`${openRequired} members`}</Text>
382
+ </KVRow>
383
+ </View>
384
+ </DrawerScrollArea>
383
385
  <View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 12 }}>
384
386
  {/* the dispatch fit-gate: every candidate names its week; the
385
387
  slot's own members are excluded, overtime is visible */}
@@ -8,7 +8,7 @@ import { Breakdown } from "@lotics/ui/breakdown";
8
8
  import { Button } from "@lotics/ui/button";
9
9
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
10
10
  import { Divider } from "@lotics/ui/divider";
11
- import { Drawer } from "@lotics/ui/drawer";
11
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
12
12
  import { EmptyState } from "@lotics/ui/empty_state";
13
13
  import { KPIStrip } from "@lotics/ui/kpi_strip";
14
14
  import { Pagination } from "@lotics/ui/pagination";
@@ -358,21 +358,23 @@ export function TplStock() {
358
358
  onNext={openIdx < pageRows.length - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
359
359
  position={`${page * PAGE_SIZE + openIdx + 1}/${filtered.length.toLocaleString("en-US")}`}
360
360
  >
361
- <View key={open.id} style={{ flex: 1, padding: 24, gap: 12 }}>
362
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
363
- <Badge label={statusOf(open.status).label} color={statusOf(open.status).color} />
364
- <Badge label={typeOf(open.type).label} />
365
- </View>
366
- <View style={{ gap: 6, paddingTop: 4 }}>
367
- <KVRow label="Depot"><Text size="sm">{depotOf(open.depot).label}</Text></KVRow>
368
- <Divider />
369
- <KVRow label="Last movement"><Text size="sm" tabular>{open.lastMove}</Text></KVRow>
370
- <Divider />
371
- <KVRow label="Age"><Text size="sm" tabular>{`${open.ageYears} years`}</Text></KVRow>
372
- <Divider />
373
- <KVRow label="Book value"><Text size="sm" tabular>{formatMoney(open.value)}</Text></KVRow>
361
+ <DrawerScrollArea key={open.id}>
362
+ <View style={{ gap: 12 }}>
363
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
364
+ <Badge label={statusOf(open.status).label} color={statusOf(open.status).color} />
365
+ <Badge label={typeOf(open.type).label} />
366
+ </View>
367
+ <View style={{ gap: 6, paddingTop: 4 }}>
368
+ <KVRow label="Depot"><Text size="sm">{depotOf(open.depot).label}</Text></KVRow>
369
+ <Divider />
370
+ <KVRow label="Last movement"><Text size="sm" tabular>{open.lastMove}</Text></KVRow>
371
+ <Divider />
372
+ <KVRow label="Age"><Text size="sm" tabular>{`${open.ageYears} years`}</Text></KVRow>
373
+ <Divider />
374
+ <KVRow label="Book value"><Text size="sm" tabular>{formatMoney(open.value)}</Text></KVRow>
375
+ </View>
374
376
  </View>
375
- </View>
377
+ </DrawerScrollArea>
376
378
  <View
377
379
  style={{
378
380
  borderTopWidth: 1,
@@ -7,7 +7,7 @@ import { Badge } from "@lotics/ui/badge";
7
7
  import { Button } from "@lotics/ui/button";
8
8
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
9
9
  import { Divider } from "@lotics/ui/divider";
10
- import { Drawer } from "@lotics/ui/drawer";
10
+ import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
11
11
  import { Heatmap, type HeatmapCell } from "@lotics/ui/heatmap";
12
12
  import { KPIStrip } from "@lotics/ui/kpi_strip";
13
13
  import { PressableRow } from "@lotics/ui/pressable_row";
@@ -306,34 +306,36 @@ export function TplTower() {
306
306
  onNext={open.idx < walk.length - 1 ? () => setOpen({ ...open, idx: open.idx + 1 }) : undefined}
307
307
  position={`${open.idx + 1}/${walk.length}`}
308
308
  >
309
- <View key={unit.key} style={{ flex: 1, padding: 24, gap: 16 }}>
310
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
311
- <Badge label={stateOf(unit.state).label} color={stateOf(unit.state).color} />
312
- <Badge label={siteOf(unit.site).label} />
309
+ <DrawerScrollArea key={unit.key}>
310
+ <View style={{ gap: 16 }}>
311
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
312
+ <Badge label={stateOf(unit.state).label} color={stateOf(unit.state).color} />
313
+ <Badge label={siteOf(unit.site).label} />
314
+ </View>
315
+ {unit.message ? (
316
+ <Text size="sm" color="danger">{`${unit.message} (${formatAge(unit.ageMin ?? 0)} ago)`}</Text>
317
+ ) : null}
318
+ <View style={{ gap: 6 }}>
319
+ <KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
320
+ <Divider />
321
+ <KVRow label="Model"><Text size="sm" tabular>{unit.model}</Text></KVRow>
322
+ <Divider />
323
+ <KVRow label="Hours since service"><Text size="sm" tabular>{unit.serviceHours.toLocaleString("en-US")}</Text></KVRow>
324
+ </View>
325
+ <View style={{ gap: 8, paddingTop: 4 }}>
326
+ <Text size="sm" weight="semibold">Recent events</Text>
327
+ <Timeline
328
+ items={[
329
+ ...(unit.message
330
+ ? [{ id: "alarm", icon: "circle-alert" as const, iconColor: solid(stateOf(unit.state).color), label: unit.message, right: <Text size="xs" color="muted" tabular>{formatAge(unit.ageMin ?? 0)} ago</Text> }]
331
+ : []),
332
+ { id: "cycle", icon: "activity" as const, iconColor: solid("blue"), label: `Completed ${120 + hash(unit.serviceHours, 9) % 300} cycles today`, right: <Text size="xs" color="muted" tabular>today</Text> },
333
+ { id: "service", icon: "settings" as const, iconColor: colors.zinc[500], label: "Scheduled service completed", right: <Text size="xs" color="muted" tabular>{`${1 + hash(unit.serviceHours, 10) % 20}d ago`}</Text> },
334
+ ]}
335
+ />
336
+ </View>
313
337
  </View>
314
- {unit.message ? (
315
- <Text size="sm" color="danger">{`${unit.message} (${formatAge(unit.ageMin ?? 0)} ago)`}</Text>
316
- ) : null}
317
- <View style={{ gap: 6 }}>
318
- <KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
319
- <Divider />
320
- <KVRow label="Model"><Text size="sm" tabular>{unit.model}</Text></KVRow>
321
- <Divider />
322
- <KVRow label="Hours since service"><Text size="sm" tabular>{unit.serviceHours.toLocaleString("en-US")}</Text></KVRow>
323
- </View>
324
- <View style={{ gap: 8, paddingTop: 4 }}>
325
- <Text size="sm" weight="semibold">Recent events</Text>
326
- <Timeline
327
- items={[
328
- ...(unit.message
329
- ? [{ id: "alarm", icon: "circle-alert" as const, iconColor: solid(stateOf(unit.state).color), label: unit.message, right: <Text size="xs" color="muted" tabular>{formatAge(unit.ageMin ?? 0)} ago</Text> }]
330
- : []),
331
- { id: "cycle", icon: "activity" as const, iconColor: solid("blue"), label: `Completed ${120 + hash(unit.serviceHours, 9) % 300} cycles today`, right: <Text size="xs" color="muted" tabular>today</Text> },
332
- { id: "service", icon: "settings" as const, iconColor: colors.zinc[500], label: "Scheduled service completed", right: <Text size="xs" color="muted" tabular>{`${1 + hash(unit.serviceHours, 10) % 20}d ago`}</Text> },
333
- ]}
334
- />
335
- </View>
336
- </View>
338
+ </DrawerScrollArea>
337
339
  <View
338
340
  style={{
339
341
  borderTopWidth: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "32.0.0",
3
+ "version": "34.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -13,11 +13,28 @@ import type { TextSize } from "./text";
13
13
  */
14
14
  export type AvatarSize = "sm" | "md" | "lg" | "xl";
15
15
 
16
+ /**
17
+ * THE RUNGS.
18
+ *
19
+ * `md` IS `CONTROL_CONTENT_HEIGHT`, and that is the whole design. An avatar is
20
+ * the tallest thing in any row that holds one, so a rung that overshoots its
21
+ * container does not look slightly big — it RESIZES the container. The default
22
+ * rung therefore has to be the largest a 40px control band can seat, and that
23
+ * number was already fixed elsewhere in the kit: 28, what `InlineButton` and a
24
+ * `md` `IconButton` use to sit inside a field. An avatar is not a special kind
25
+ * of child; it gets the same height they do.
26
+ *
27
+ * It was 36, which fits nothing. The same member chip measured 46 in a `Select`
28
+ * trigger, 50 in an inline editor and 54 in an `InlineSlot` — three heights for
29
+ * one control band, because each surface had picked its own inset and none of
30
+ * them had a number to pick it FROM.
31
+ */
16
32
  export const AVATAR_PX: Record<AvatarSize, number> = {
17
33
  /** Dense rows and inline chips — one initial, because two do not fit. */
18
34
  sm: 24,
19
- /** The default: registers, lists, anywhere a person is a row. */
20
- md: 36,
35
+ /** The default: a 40px control band, a register, a list — anywhere a person is
36
+ * a row. Equals `CONTROL_CONTENT_HEIGHT` (`control_surface`). */
37
+ md: 28,
21
38
  /** Prominent rows, the account menu. */
22
39
  lg: 48,
23
40
  /** A profile's own header. */
@@ -32,19 +49,27 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
32
49
  * native, and `text.css` moves again at 768px, so these are the desktop numbers
33
50
  * and only a render can give them):
34
51
  *
35
- * sm 12 on 24 = 50% · md 14 on 36 = 39%
36
- * lg 20 on 48 = 42% · xl 32 on 72 = 44%
52
+ * sm 12 on 24 = 50%
53
+ * md 12 on 28 = 43%
54
+ * lg 20 on 48 = 42%
55
+ * xl 32 on 72 = 44%
37
56
  *
38
- * The two-letter rungs sit within 6 points of each other, so an avatar reads the
57
+ * The two-letter rungs sit within 2 points of each other, so an avatar reads the
39
58
  * same at every size rather than growing emptier as it grows — `lg` was on `md`
40
59
  * type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by design:
41
60
  * one letter in a small circle needs the weight.
61
+ *
62
+ * `md` shares `sm`'s rung and is NOT the odd one out for it: the same 12px reads
63
+ * as 50% of a 24px circle holding one letter and 43% of a 28px circle holding
64
+ * two. Keeping it on `sm` type (14) would have put `md` at 50% — the fattest of
65
+ * the two-letter rungs, and the same defect in the other direction as the `lg`
66
+ * one this table was written to fix.
42
67
  */
43
68
  export const AVATAR_TEXT: Record<AvatarSize, TextSize> = {
44
69
  sm: "xs", // 12 on 24 — one letter, so it carries a larger share of the circle
45
- md: "sm", // 14 on 36
46
- lg: "lg", // 18 on 48
47
- xl: "xxl", // 28 on 72
70
+ md: "xs", // 12 on 28
71
+ lg: "lg", // 20 on 48
72
+ xl: "xxl", // 32 on 72
48
73
  };
49
74
 
50
75
  const words = (name: string): string[] => name.trim().split(/\s+/).filter((w) => w !== "");
@@ -5,6 +5,38 @@ import { colors } from "./colors";
5
5
  * buttons) aligns to it so a toolbar row reads as one band. */
6
6
  export const CONTROL_HEIGHT = 40;
7
7
 
8
+ /**
9
+ * THE TALLEST FIXED-HEIGHT NODE A CONTROL BAND CAN SEAT — and so the inner height
10
+ * every 40px surface must afford. 6 per side of air, BORDER INCLUDED.
11
+ *
12
+ * `CONTROL_HEIGHT` is a promise — a toolbar row, a record's value column and a
13
+ * picker's option list read as one band because every control on them is 40. TEXT
14
+ * keeps that promise for free: a line is ~20, so `minHeight` governs and a
15
+ * surface's padding never binds. That is precisely why the promise broke the
16
+ * first time a control was handed something with a FIXED height. Each surface had
17
+ * quietly picked its own inset — 4, 6, 8 — with no number to pick it from, and
18
+ * nothing on a resting screen could reveal the disagreement; the moment a 36px
19
+ * avatar went in, the same member chip came out 46 in a `Select` trigger, 50 in
20
+ * an inline editor and 54 in an `InlineSlot`.
21
+ *
22
+ * 28 is not a fresh choice: it is what `InlineButton` and an `IconButton` at `md`
23
+ * had ALREADY settled on for sitting inside a field, air and all. Naming it once
24
+ * is what stops the next fixed-height child inventing a fourth number, and it is
25
+ * why an avatar on the default rung is 28 too — a control's children should not
26
+ * differ in height by what kind of thing they are.
27
+ *
28
+ * Anything taller does not belong in a control at all; it belongs in a row that
29
+ * sets its own height.
30
+ */
31
+ export const CONTROL_CONTENT_HEIGHT = 28;
32
+
33
+ /** A control's own vertical inset, derived so the band seats
34
+ * {@link CONTROL_CONTENT_HEIGHT} at exactly {@link CONTROL_HEIGHT}. The 1px
35
+ * border is part of the 6, so a bordered surface pads by this and an unbordered
36
+ * one by this + 1 — reading the padding and forgetting the border is what let
37
+ * three surfaces drift in the first place. */
38
+ export const CONTROL_PADDING_V = (CONTROL_HEIGHT - CONTROL_CONTENT_HEIGHT) / 2 - 1;
39
+
8
40
  /** The narrowest a control may be and still be usable — below it, editors get
9
41
  * crushed and option text has nowhere to go. It is the threshold at which a
10
42
  * `DetailTable` gives up its side-by-side columns and stacks, and the floor
package/src/drawer.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ReactNode, useEffect } from "react";
2
- import { Modal, Pressable, StyleSheet, View } from "react-native";
2
+ import { Modal, Pressable, ScrollView, StyleSheet, View } from "react-native";
3
3
  import { useScreenSize } from "@lotics/ui/use_screen_size";
4
4
  import { SizeBoundary } from "@lotics/ui/size_boundary";
5
5
  import { PortalHost } from "@lotics/ui/portal";
@@ -9,6 +9,19 @@ import { Text } from "@lotics/ui/text";
9
9
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
10
10
  import { useLoticsLocale } from "@lotics/ui/locale";
11
11
 
12
+ /**
13
+ * The panel's inset — the ONE left edge the header, `DrawerScrollArea` and the footer
14
+ * all sit on.
15
+ *
16
+ * `DrawerScrollArea` applies it, so an ordinary body never names this. It is
17
+ * exported for the surfaces that cannot use it: a MIXED body (a full-bleed ruled
18
+ * bar over guttered content) and a PINNED one (a heading or summary held above a
19
+ * scrolling list, which needs the gutter on both parts). A hardcoded `20` there
20
+ * silently stops matching the day this one moves — same reason `AVATAR_PX` is
21
+ * exported.
22
+ */
23
+ export const DRAWER_GUTTER = 20;
24
+
12
25
  export interface DrawerProps {
13
26
  open: boolean;
14
27
  onOpenChange: (open: boolean) => void;
@@ -32,9 +45,12 @@ export interface DrawerProps {
32
45
  /** Position caption between the chevrons ("3/24"). */
33
46
  position?: string;
34
47
  /**
35
- * Drawer body. Fills the panel below the header as a plain flex column — lay
36
- * out your own scroll area and pinned footer (e.g. a thread that scrolls above
37
- * a fixed composer).
48
+ * Drawer body a BARE flex column filling the panel below the header, so a
49
+ * full-bleed child reaches the panel edge.
50
+ *
51
+ * Wrap content in `DrawerScrollArea` to get the panel's gutter; that is where
52
+ * the padding lives, and hand-rolling it is how a body ends up on a different
53
+ * left edge from the title above it. A `DrawerFooter` goes last, after it.
38
54
  */
39
55
  children: ReactNode;
40
56
  testID?: string;
@@ -134,6 +150,41 @@ export function Drawer(props: DrawerProps) {
134
150
  );
135
151
  }
136
152
 
153
+ export interface DrawerScrollAreaProps {
154
+ children: ReactNode;
155
+ }
156
+
157
+ /**
158
+ * The scrolling, guttered content region of a `Drawer` — the exact counterpart of
159
+ * `DialogScrollArea`, and named to match it.
160
+ *
161
+ * It exists because the gutter is a CONTRACT, not a convenience: the content has
162
+ * to sit on the same left edge as the header's title and the footer's actions, and
163
+ * the panel is the only thing that knows that number. Left to callers, it was
164
+ * answered three different ways across the templates and the live apps — `24` six
165
+ * times (4px past the drawer's own title), `20` three times, and in one shipped
166
+ * app not at all, which put a comment thread flush against the panel edge while
167
+ * its title was inset 20.
168
+ *
169
+ * 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
173
+ * scrolling one is not this component: it goes in the `Drawer`'s bare slot and
174
+ * puts `DRAWER_GUTTER` on the pinned part's padding AND on its scroller's
175
+ * `contentContainerStyle`, which is the only arrangement where content still
176
+ * reaches the panel edge.
177
+ *
178
+ * A body that must reach the panel edge — a full-bleed band, a whole record screen
179
+ * carrying its own gutters — likewise does NOT wrap in this, which is why the
180
+ * `Drawer` leaves its slot bare. That is also how `Modal` and `Dialog` work; a
181
+ * `Drawer` that padded its own slot would be the one overlay of the three that
182
+ * does, and would force every full-bleed child to cancel it with a negative margin.
183
+ */
184
+ export function DrawerScrollArea(props: DrawerScrollAreaProps) {
185
+ return <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>;
186
+ }
187
+
137
188
  export interface DrawerFooterProps {
138
189
  /** The action(s) — typically right-aligned `Button`s. Prepend a
139
190
  * `<Text style={{ flex: 1 }}>` hint to push them right with a summary on the left. */
@@ -155,7 +206,7 @@ const styles = StyleSheet.create({
155
206
  footer: {
156
207
  borderTopWidth: 1,
157
208
  borderTopColor: colors.border,
158
- paddingHorizontal: 20,
209
+ paddingHorizontal: DRAWER_GUTTER,
159
210
  paddingVertical: 14,
160
211
  flexDirection: "row",
161
212
  alignItems: "center",
@@ -183,7 +234,10 @@ const styles = StyleSheet.create({
183
234
  flexDirection: "row",
184
235
  alignItems: "center",
185
236
  gap: 8,
186
- paddingLeft: 20,
237
+ paddingLeft: DRAWER_GUTTER,
238
+ // Short of the gutter by design: the close IconButton carries its own
239
+ // padding, so a full gutter here would push its GLYPH inside the edge the
240
+ // title sits on.
187
241
  paddingRight: 12,
188
242
  paddingTop: 16,
189
243
  paddingBottom: 8,
@@ -197,4 +251,7 @@ const styles = StyleSheet.create({
197
251
  body: {
198
252
  flex: 1,
199
253
  },
254
+ bodyContent: {
255
+ padding: DRAWER_GUTTER,
256
+ },
200
257
  });
@@ -2,11 +2,14 @@ import { type GestureResponderEvent } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
- import { CONTROL_TRANSITION } from "./control_surface";
5
+ import { CONTROL_CONTENT_HEIGHT, CONTROL_TRANSITION } from "./control_surface";
6
6
 
7
7
  /** The height that fits INSIDE the 40px inline control band with 6px of air
8
- * above and below — the same air a `DetailRow`'s label gets against its value. */
9
- const INLINE_BUTTON_HEIGHT = 28;
8
+ * above and below — the same air a `DetailRow`'s label gets against its value.
9
+ * This file is where that number was FIRST worked out; it now lives in
10
+ * `control_surface` so an avatar or any other fixed-height child of a control
11
+ * lands on the same line rather than inventing its own. */
12
+ const INLINE_BUTTON_HEIGHT = CONTROL_CONTENT_HEIGHT;
10
13
 
11
14
  interface InlineButtonProps {
12
15
  /** The verb, as a word. An inline button is never icon-only — it sits on a
@@ -6,7 +6,7 @@ import { useLoticsLocale } from "./locale";
6
6
  import { ActivityIndicator } from "./activity_indicator";
7
7
  import { FocusRingPressable } from "./focus_ring_pressable";
8
8
  import { colors } from "./colors";
9
- import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
+ import { FOCUS_RING, CONTROL_RADIUS, CONTROL_PADDING_V, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
10
10
  import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
@@ -653,11 +653,15 @@ const styles = StyleSheet.create({
653
653
  borderColor: colors.border,
654
654
  backgroundColor: colors.white,
655
655
  paddingHorizontal: 8,
656
- // Vertical padding matters only when the display content is TALLER than
657
- // one line (a rich select showing title + description at rest): minHeight
658
- // still governs single-line rows, so those render pixel-identical, while
659
- // multi-line content gets breathing room instead of touching the edges.
660
- paddingVertical: 6,
656
+ // Vertical padding binds on two kinds of content, never on a single line
657
+ // (minHeight governs there, so those rows render pixel-identical whatever
658
+ // this is): content TALLER than one line, which gets breathing room instead
659
+ // of touching the edges, and content of FIXED height — an avatar, a swatch —
660
+ // which this decides the field's whole height for. Hence the shared
661
+ // constant: an editor seats a `CONTROL_CONTENT_HEIGHT` node at exactly 40,
662
+ // the same as every other 40px surface, rather than at a number this file
663
+ // happened to choose.
664
+ paddingVertical: CONTROL_PADDING_V,
661
665
  flexDirection: "row",
662
666
  alignItems: "center",
663
667
  gap: 6,
@@ -1,6 +1,6 @@
1
1
  import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
2
  import { type ReactNode } from "react";
3
- import { CONTROL_RADIUS } from "./control_surface";
3
+ import { CONTROL_PADDING_V, CONTROL_RADIUS } from "./control_surface";
4
4
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
5
 
6
6
  export interface InlineSlotProps {
@@ -46,13 +46,16 @@ const styles = StyleSheet.create({
46
46
  borderWidth: 1,
47
47
  borderColor: "transparent",
48
48
  paddingHorizontal: 8,
49
- // The editors' vertical padding too, not just the horizontal. A single line
50
- // hides its absence the box is `minHeight` 40 and the text is ~20, so both
51
- // sides land on 40 whether or not this exists but an auto-growing input
52
- // sizes itself to content PLUS its padding and border, so from the SECOND
53
- // line on, a value without this sits ~14px shorter than the input that
54
- // replaces it. Matching by coincidence at one line is not matching.
55
- paddingVertical: 8,
49
+ // The editors' vertical padding too, not just the horizontal and it comes
50
+ // from the shared constant, NOT from the 8 the horizontal happens to use. A
51
+ // single line hides both the absence and a mismatch: the box is `minHeight`
52
+ // 40 and the text is ~20, so any padding from 0 to 10 lands on 40. It bites
53
+ // from the SECOND line on (an auto-growing input sizes to content PLUS its
54
+ // padding and border, so a value without this sits shorter than the input
55
+ // that replaces it) and on any FIXED-height content, where this slot's old
56
+ // hand-picked 8 made it 4px taller than the editor beside it for the very
57
+ // same child. Matching by coincidence at one line is not matching.
58
+ paddingVertical: CONTROL_PADDING_V,
56
59
  justifyContent: "center",
57
60
  },
58
61
  });
@@ -12,7 +12,8 @@ interface MemberChipProps {
12
12
  image?: string | null;
13
13
  /** Optional secondary line under the name — e.g. email, role, department. */
14
14
  secondary?: string | null;
15
- /** A rung on the shared avatar scale. Default `md`. */
15
+ /** A rung on the shared avatar scale. Default `md`, which is sized to seat in a
16
+ * 40px control band; drop to `sm` for a dense cell. See `AVATAR_PX`. */
16
17
  size?: AvatarSize;
17
18
  style?: StyleProp<ViewStyle>;
18
19
  }
@@ -26,6 +27,12 @@ interface MemberChipProps {
26
27
  * Pure: pass the member's fields in (from `useMembers`, or a resolved
27
28
  * `select_member` cell joined against that roster); this component fetches
28
29
  * nothing and carries no domain types.
30
+ *
31
+ * A chip's usual home is a 40px CONTROL BAND — a picker option, a select's
32
+ * trigger, an inline editor at rest, a diff slot — and the default `md` is sized
33
+ * to seat in one (`CONTROL_CONTENT_HEIGHT`). That is not a coincidence to
34
+ * preserve by hand: it is why `md` is 28 rather than the 36 that used to grow
35
+ * those three surfaces to 46, 50 and 54 for the very same chip.
29
36
  */
30
37
  export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
31
38
  const displayName = name?.trim() || "Unknown";
package/src/select.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
2
2
  import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
3
3
  import { colors } from "./colors";
4
- import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
4
+ import { FOCUS_RING, CONTROL_RADIUS, CONTROL_HEIGHT, CONTROL_PADDING_V, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
5
5
  import { FocusRingPressable } from "./focus_ring_pressable";
6
6
  import { Text } from "./text";
7
7
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
@@ -293,11 +293,14 @@ const styles = StyleSheet.create({
293
293
  flexDirection: "row",
294
294
  alignItems: "center",
295
295
  paddingHorizontal: 6,
296
- paddingVertical: 4,
296
+ // Shared, so a trigger seats a `CONTROL_CONTENT_HEIGHT` node at exactly
297
+ // `CONTROL_HEIGHT` — the hand-picked 4 here made this trigger 4px shorter
298
+ // than the inline editor holding the identical chip.
299
+ paddingVertical: CONTROL_PADDING_V,
297
300
  borderWidth: 1,
298
301
  borderColor: colors.border,
299
302
  borderRadius: CONTROL_RADIUS,
300
- minHeight: 40,
303
+ minHeight: CONTROL_HEIGHT,
301
304
  },
302
305
  opened: {
303
306
  // Mouse-opened, so the trigger never takes keyboard focus — wear the same 2px