@lotics/ui 46.1.0 → 46.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -75,6 +75,30 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
75
75
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
76
76
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
77
77
  → [composition.md §"Color discipline"](./docs/composition.md).
78
+ - **An overlay paints in the order it was OPENED, and a scroller that swaps content opens the new
79
+ content at the top.** Compose overlays wherever you like — a `Dialog` written beside the
80
+ `Drawer` whose row opens it works, both ways round, and a `Dialog` opened from an open `Popover`
81
+ paints over its panel. An overlay of your OWN gets that for free by rendering its react-native
82
+ `Modal` **only while open** (`{open && <Modal visible …>}`); mounted while closed it takes its
83
+ body-level slot on the app's first paint and is then covered by anything opened later —
84
+ rendering and announcing perfectly, dead to every press. The z-index rungs beside the overlays
85
+ are published in `overlay_layer`, so never hand-pick a number to clear a Lotics overlay.
86
+ `DrawerScrollArea` / `DialogScrollArea` / `ModalBody` take a `scrollKey` and the routed
87
+ `Popover` takes the same seam off its route, so a swapped-in record opens at the top and the
88
+ reader returns to their place on the way back.
89
+ → [composition.md §"Canvas & content column"](./docs/composition.md)
90
+ → [catalog.md §"Overlays & navigation"](./docs/catalog.md)
91
+ - **ONE VALUE, ONE RENDERING, inside one card — the chart draws the entity its table draws.**
92
+ A value's filter renders it the way its cell does, and the same rule reaches the chart beside
93
+ the table: `StackedBarRow` and `BarChartItem` take a `leading` slot for the entity's own mark.
94
+ A LEGEND row is the exception — its subject is a category, and the swatch is that identity.
95
+ → [composition.md §"A register that TRIAGES"](./docs/composition.md).
96
+ - **A row says which side HOLDS; `numberOfLines` handles the side that gives way.** The clamp
97
+ brings its own `minWidth: 0` + `flexShrink: 1` (a clamp that cannot shrink cannot clamp), so
98
+ what a call site still has to state is `flexShrink: 0` on the figure or control that must stay
99
+ whole. Same law on a categorical axis: the LABELS thin to fit, the bars never do.
100
+ → [composition.md §"Typography"](./docs/composition.md),
101
+ [catalog.md §"Numbers & charts"](./docs/catalog.md).
78
102
  - **Hand-typed type is off-system — and it always lands too small.** Every run of language is
79
103
  `<Text size= weight= color=>` on the fixed rungs. **Grep the diff: `fontSize:` / `lineHeight:` /
80
104
  `letterSpacing:` outside `src/` is a bug.** → [composition.md §"Typography"](./docs/composition.md).
package/MIGRATION.md CHANGED
@@ -4,6 +4,93 @@ 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
+ ## 46.3.0
8
+
9
+ **A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
10
+ A clamp that cannot shrink cannot clamp: on web a clamped `Text` is `white-space: nowrap`, so
11
+ `min-width: auto` floors it at the WHOLE string; on native a `Text` is `flexShrink: 0`. Either way
12
+ two clamped values on one row laid out at intrinsic width and ran off the frame. Both declarations
13
+ now come with the clamp, and both are no-ops until a row actually overflows.
14
+
15
+ Nothing's API changed, but **a layout default moved under every clamped `Text` in your app** — this
16
+ is the one 46.3.0 change to re-check by looking. Two shapes are worth a pass:
17
+
18
+ - A flex ROW where a clamped `Text` was the side that HELD and an unclamped sibling gave way. Both
19
+ can shrink now, so the wrong one may truncate.
20
+ - A flex COLUMN with a bounded height (a fixed-height card, a `maxHeight` panel). `flexShrink` acts
21
+ on the MAIN axis, so a clamped `Text` that used to hold its height can now be compressed.
22
+
23
+ The pair sits BEFORE your own `style`, so the site that must stay whole says so:
24
+
25
+ ```diff
26
+ -<Text numberOfLines={1}>{amount}</Text>
27
+ +<Text numberOfLines={1} style={{ flexShrink: 0 }}>{amount}</Text>
28
+ ```
29
+
30
+ That is how a figure keeps winning over its label. `minWidth` is overridden the same way
31
+ (`style={{ minWidth: "auto" }}`) on the rare surface that wants the old min-content floor back.
32
+
33
+ **An overlay of your OWN must render its react-native `Modal` only while it is OPEN.**
34
+ `Dialog`, `Drawer`, `Modal` and `FileGalleryModal` now do — nothing in their API changed — and a
35
+ hand-rolled overlay outside the kit has to do the same, or it loses to them:
36
+
37
+ ```diff
38
+ -<Modal visible={open} onRequestClose={close} transparent>
39
+ - …
40
+ -</Modal>
41
+ +{open && (
42
+ + <Modal visible onRequestClose={close} transparent>
43
+ + …
44
+ + </Modal>
45
+ +)}
46
+ ```
47
+
48
+ react-native-web appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders
49
+ it, so an overlay mounted while closed takes its slot on the app's first paint and is then covered
50
+ by any overlay opened later — while rendering, reading and announcing perfectly, with only its
51
+ presses dead. Nothing is lost by the change: react-native-web renders a closed `Modal`'s children
52
+ as `null` already, so only the empty portal div was being held. A takeover with
53
+ `animationType="fade"` keeps its fade IN and now closes immediately, as `FileGalleryModal` always
54
+ has; state that must survive a close belongs outside the `Modal` element either way.
55
+
56
+ **The overlay z-index rungs are now published constants.** The numbers are unchanged for `Tooltip`
57
+ and `Alert` (`10000`) and for a toast (`10001`); **the skip link moved `10001` → `10002`**, off the
58
+ rung it was sharing with a toast. Read the rung instead of naming a number:
59
+
60
+ ```diff
61
+ -import { View } from "react-native";
62
+ +import { NOTIFICATION_Z } from "@lotics/ui/overlay_layer";
63
+
64
+ - zIndex: 10001,
65
+ + zIndex: NOTIFICATION_Z,
66
+ ```
67
+
68
+ The full table is in `docs/catalog.md` § Overlays & navigation: `OVERLAY_Z` (every overlay, and
69
+ `Popover`), `OVERLAY_Z_ABOVE` (tooltip/alert), `NOTIFICATION_Z` (a toast), `SKIP_LINK_Z`.
70
+
71
+ **`PageContent`'s title band is now a `PageHeader`**, so the two stop drifting. `title`,
72
+ `titleRight` and `description` are unchanged, and `PageHeader`'s `title` became optional — but the
73
+ band's rhythm is now `PageHeader`'s everywhere: a description sits **8px** under the title (it was
74
+ flush in `PageHeader`) and the band clears **16px** before the content (it was 24 in
75
+ `PageContent`). Nothing to change; worth one look at a page shell you care about.
76
+
77
+ **`useChangeSet`'s per-proposal default is a MAP, not a predicate.** `initial: "accepted"` /
78
+ `"pending"` are unchanged.
79
+
80
+ ```diff
81
+ -useChangeSet(ids, { initial: (id) => (overwrites.has(id) ? "rejected" : "accepted") })
82
+ +const initial = useMemo(
83
+ + () => new Map(rows.filter((r) => r.overwrites).map((r) => [r.id, "rejected" as const])),
84
+ + [rows],
85
+ +);
86
+ +useChangeSet(ids, { initial })
87
+ ```
88
+
89
+ An id the map does not name arrives `accepted`, as before. A value rather than a function is what
90
+ lets the default be an ordinary dependency: `status` and the group arrays derive from it together,
91
+ so `status` changes identity whenever a decision does — which a screen that memoizes its rows on
92
+ `review.status` needs, and which a predicate held out of the dependency lists could not give.
93
+
7
94
  ## 46.0.0
8
95
 
9
96
  **`AgentRun` / `AgentProgress` drop `stepsLabel`, and the `agentRun` locale slice drops `steps`.**
@@ -177,8 +177,15 @@ PUT and rolls the calls out BELOW it on press.
177
177
 
178
178
  **ONE row for the whole run, and NO prose until it ends.** While the agent works, the entire feed
179
179
  is a single row. It names the call in flight — "Searching records" — and says
180
- **"Thinking"** whenever the model is writing rather than calling, which is every gap between
181
- calls. The dot pulses; nothing rolls out unless the reader asks.
180
+ **"Thinking"** whenever the model is writing rather than calling, which is every gap between
181
+ calls. Nothing rolls out unless the reader asks.
182
+
183
+ **An unsettled row BREATHES**, label and dot together — the label on the same opacity rhythm the
184
+ dot's halo runs (750ms each way), so one line carries one animation rather than two that drift
185
+ apart. That breath is what says "in progress", which is why no live label ends in an ellipsis:
186
+ punctuation was standing in for motion, and a screen reader drops it anyway. A row that is not
187
+ working does not breathe — settled, and `awaiting` a human decision, which is parked rather than
188
+ in flight.
182
189
 
183
190
  **No step count.** The row says WHAT is happening and stops there. A tally was a number the reader
184
191
  could act on in no way: live, the label already changes on every call, so it added no motion the
@@ -208,12 +215,14 @@ reasoning part. An interleaved-thinking run therefore rebuilt the stack one grou
208
215
  reader watched finished rows pile up while they waited, and was then left with that pile sitting
209
216
  above the answer at the answer's weight, every row of it stale.
210
217
 
211
- **Expanding is where live and settled differ, and the only place they do.** Opened mid-run the
212
- timeline is CAPPED and self-pinning (220px, ~5 rows) someone who opens a run in flight asked to
213
- see the work, not to hand the page a feed that grows for another minute; `FollowScroll` follows at
214
- LAYOUT level so each call paints already pinned. Opened once settled it is uncapped: nothing is
215
- arriving, so there is nothing to cap. Settling never closes what a reader opened; it just stops
216
- capping it.
218
+ **Expanding works the same live and settled** the timeline opens IN FULL, and the HOST'S OWN
219
+ scroller keeps the newest row in view. `AgentRun` never frames itself. It shipped once with a
220
+ live-only 220px window, and that window was a scroll container nested inside whatever scroll
221
+ container the run already sat in: in chat, an inverted list inside an inverted list, which painted
222
+ stale content over the neighbouring messages and took the wheel from the surface the reader was
223
+ on. A component in document flow cannot know whether it is inside a scroller, so it must not act
224
+ as one. A host that genuinely needs a bound takes the framing job itself — `collapseProcess={false}`
225
+ plus its own `FollowScroll`. Settling never closes what a reader opened.
217
226
 
218
227
  The split is POSITIONAL (`splitTimeline` in `agent_transform`): what comes after the last tool
219
228
  call is the answer, everything before it is the work. Nothing has to decide whether a paragraph
@@ -261,10 +270,10 @@ tinted panel rather than another step row, and it carries `role="alert"` so a sc
261
270
  action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
262
271
  and the panel carries the message alone.
263
272
 
264
- **`FollowScroll` — only when you took the job (`collapseProcess={false}`).** A run opened mid-flight
265
- already caps itself with exactly this, so wrapping the default AGAIN nests a scroller in a
266
- scroller: the outer never overflows, the inner holds the content, and the wheel lands on whichever
267
- the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
273
+ **`FollowScroll` — only when you took the job (`collapseProcess={false}`).** On the default, the
274
+ run is plain content in your flow and your own scroller follows it; adding a wrapper there nests a
275
+ scroller in a scroller — the outer never overflows, the inner holds the content, and the wheel
276
+ lands on whichever the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
268
277
  framing yourself — a dialog, drawer, or fixed-height panel where the feed grows BELOW the fold and
269
278
  a plain scroll container doesn't follow. Wrap it then:
270
279
  `<FollowScroll style={{ maxHeight: … }}><AgentRun … collapseProcess={false} /></FollowScroll>` — the same
@@ -281,7 +290,7 @@ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={
281
290
  [the SDK doc](../../app-sdk/docs/ai.md) for the hook. The `ai` package is a regular dependency of
282
291
  `@lotics/ui` — its part TYPES resolve transitively in any consumer's typecheck with nothing to
283
292
  install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
284
- **A run with no parts yet renders the breathing "Starting" row** (streaming state only) — the
293
+ **A run with no parts yet renders the breathing "Starting" row** (streaming state only) — the
285
294
  live dot plus a label that pulses, covering the gap between the CTA press and the first streamed
286
295
  part (upload + run creation + first token). **The law: pass
287
296
  `state="streaming"` from the moment the paid CTA fires — uploads included — and render
@@ -370,6 +379,17 @@ alignment and rhythm.
370
379
  `keptCount`/`total`/`settled`. `initial` defaults to **`accepted`** so an operator drops
371
380
  exceptions instead of approving eight identical lines; pass `pending` when each change
372
381
  genuinely deserves its own verdict, and gate the commit on `settled`.
382
+ - **A set that mixes KINDS defaults per kind: `initial` also takes a `Map<id, decision>`.** A
383
+ review usually holds both — filling a blank should arrive `accepted`, overwriting a value a
384
+ human already set should arrive `rejected`, so the destructive half is opt-in — and one
385
+ global default puts one of the two on the wrong footing. An id the map does not name arrives
386
+ `accepted`. Build it with a `useMemo` over the proposals your screen already holds, which is
387
+ what keeps the hook ignorant of what a proposal IS; a VALUE (rather than a predicate written
388
+ inline) is what lets `status` and the group arrays derive from it together, so `status`
389
+ changes identity the moment a decision moves and a screen memoizing its rows on it re-runs.
390
+ Deciding those rows by calling `reject()` from the result handler instead writes overrides
391
+ nobody made, and `undo` then hands the row back **accepted** — the destructive change
392
+ silently in, from the control that means "undo".
373
393
 
374
394
  ### The shapes, and what each one is
375
395
 
@@ -518,7 +538,7 @@ whole pane with no backend ([`tpl_item_list`](../examples/tpl_item_list.tsx) doe
518
538
 
519
539
  **Customize through the seams, or drop to the primitives.** `labelForCall` / `renderToolOutput`
520
540
  pass through to `AgentRun`; the `run` object is the data seam. There is deliberately NO
521
- empty-state slot — `AgentRun` renders its own localized "Starting" row on zero parts, and the law
541
+ empty-state slot — `AgentRun` renders its own localized "Starting" row on zero parts, and the law
522
542
  above forbids hand-rolling a placeholder. An app that wants a different ARRANGEMENT does not fight
523
543
  the pane: `AgentRun`, `ClarifyWizard`, `ClarifyWizardScope`/`Actions`, `FollowScroll` and
524
544
  `DialogScrollArea` all remain exported, and composing them is what this pane itself does.
package/docs/catalog.md CHANGED
@@ -443,6 +443,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
443
443
  numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
444
444
  box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
445
445
  its annotation; see composition.md, which also covers the `gap` that goes with it.
446
+ **`numberOfLines`** clamps, and brings the `minWidth: 0` + `flexShrink: 1` that makes clamping
447
+ possible on a flex row — a clamp that cannot shrink cannot clamp. Both are no-ops until the row
448
+ overflows, and a `style` on the call site still wins, so a value that must never give way says
449
+ `flexShrink: 0` and keeps holding over its own label.
446
450
  - **`eyebrow`** — `Eyebrow`: the small quiet label above or beside a VALUE (`xs` muted medium) —
447
451
  a metric caption, a field name in a cell, an artifact tag. Takes `color` (for a VERDICT word
448
452
  like "Mismatch") and `align`, and deliberately takes no `size`, `weight` or `transform`:
@@ -702,16 +706,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
702
706
  useSeparator` inserts and what menus/popovers put between option groups.
703
707
  - **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
704
708
  `CONTAINER_SIZES`).
705
- - **`page_header`** — `PageHeader`: the page's title band. `actions` puts page-level CTAs on
709
+ - **`page_header`** — `PageHeader`: the page's title band, and the ONE place that band is
710
+ defined — `PageContent`'s `title`/`titleRight`/`description` render one of these rather than a
711
+ second copy of the row. `actions` puts page-level CTAs on
706
712
  the title row (right-aligned) and `trailing` puts a control immediately AFTER the title;
707
713
  `left`/`right` form a separate nav row above. Split `trailing` from `actions` by what the
708
714
  control acts on: `actions` do something to the page's CONTENT (create, sort, export),
709
715
  `trailing` changes what is AROUND it (a side-panel toggle, a view switch). A panel toggle
710
716
  filed under `actions` reads as a peer of "create one of these". Under a title too long for
711
717
  the row the TITLE gives way and wraps while `trailing`
712
- and `actions` keep their width.
718
+ and `actions` keep their width. **The band owns the space under itself** — 16px, whether it
719
+ is rendered on a hand-rolled screen or by `PageContent`; a shell adds nothing after it, or
720
+ the same band closes on two different rhythms depending on which shell a screen picked.
713
721
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
714
- region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
722
+ region — a centred column with optional `title`/`titleRight`/`description` (which render a
723
+ `PageHeader`, so the band's row law and rhythm have one home), `header`/`footer`
715
724
  slots and `fullscreen`. **Reach for it before hand-rolling a screen shell.** Its side
716
725
  padding is `pagePad` (`@lotics/ui/spacing`), so a screen that
717
726
  genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
@@ -1586,15 +1595,56 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1586
1595
 
1587
1596
  ### Overlays & navigation
1588
1597
 
1598
+ **Every overlay paints in the order it was OPENED**, so mount them wherever composition wants — a
1599
+ `Dialog` written beside the `Drawer` whose row opens it is the ordinary shape and it works; a
1600
+ drawer opened from a dialog covers the dialog, which is the same rule the other way round. There
1601
+ is no layer prop and nothing at the call site has to know the tree order. It covers every
1602
+ body-level surface the kit has, `Popover` included: a `Dialog` or a file-preview `Modal` opened
1603
+ from an open popover paints OVER the panel that opened it. `Alert` and `Tooltip` stay above every
1604
+ overlay at any depth.
1605
+
1606
+ **The one rule an overlay of your OWN has to follow: render the react-native `Modal` element only
1607
+ while it is open** (`{open && <Modal visible …>}`, or an early `return null`). react-native-web
1608
+ appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders it, so an overlay
1609
+ mounted while closed has already taken its slot before anything opened — and once two overlays tie
1610
+ at the same z-index, that slot is what decides. The symptom is nasty precisely because it is not
1611
+ visual: the covered surface renders, reads and announces perfectly, and only its presses do
1612
+ nothing. Nothing is lost by not mounting it — react-native-web renders a closed `Modal`'s children
1613
+ as `null` anyway, so only the empty portal div was ever being held, and anything that must survive
1614
+ a close (a router, a context, form state) belongs outside the `Modal` element regardless.
1615
+
1616
+ **The rungs beside the overlays are a published contract** (`overlay_layer`), because a surface
1617
+ OUTSIDE the kit that has to clear a Lotics overlay cannot do it with a hand-picked literal:
1618
+
1619
+ | z-index | what sits there |
1620
+ |---|---|
1621
+ | `9999` | every overlay, and `Popover` — `OVERLAY_Z` |
1622
+ | `10000` | `Tooltip`, `Alert` — `OVERLAY_Z_ABOVE` |
1623
+ | `10001` | a transient notification / toast — `NOTIFICATION_Z` |
1624
+ | `10002` | the skip link — `SKIP_LINK_Z` |
1625
+
1626
+ Overlays all share one rung on purpose: DOM order settles the tie and DOM order is open order.
1627
+ `Popover` is on it too — it is no `Modal`, but it portals into the nearest `PortalHost`, so one
1628
+ opened inside an overlay is already inside that overlay's stacking context and a page-level one is
1629
+ a child of the app root, which every overlay's body-level box follows.
1630
+
1589
1631
  - **`portal`** — `PortalHost` + `Portal`: the floating-content mount point; the app root
1590
1632
  needs ONE `PortalHost` or `Dialog`/`Popover`/`Tooltip`/`Alert`/`OptionList` cannot render.
1591
1633
  - **`dialog`** — `Dialog` (+ `DialogHeader`/`DialogHeaderTitle`/`DialogFooter`, `useDialog`,
1592
1634
  `useDialogNavigation`): the centered card over a scrim; BAKES a screen router in
1593
- (`<Dialog><Screen route="">…` — see `screen_router`).
1635
+ (`<Dialog><Screen route="">…` — see `screen_router`). `DialogScrollArea` takes the same
1636
+ **`scrollKey`** seam as `DrawerScrollArea` for a pane that swaps its body in place; a dialog
1637
+ navigating between `Screen`s does not need it, because a stacked screen stays mounted and keeps
1638
+ its own offset.
1594
1639
  - **`drawer`** — `Drawer` + `DrawerScrollArea` + `DrawerFooter`: the docked side panel with scrim; the
1595
1640
  register row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
1596
1641
  exceptional panel; full-width on small screens. `DrawerScrollArea` is the guttered content
1597
- region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. It always
1642
+ region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. Give it a
1643
+ **`scrollKey`** (the open content's identity — `scrollKey={openChild?.id}`, where the undefined
1644
+ leg names the root content rather than opting out) on a drawer that SWAPS its body in place, so
1645
+ the new content opens at the top and the previous one's offset returns on the way back — the seam
1646
+ `DialogScrollArea` and `ModalBody` take too, and the one thing a React `key` on the scroller
1647
+ cannot buy, since that resets the parent's place along with the child's. It always
1598
1648
  scrolls: a padded box around a `flex:1` scroller insets that scroller's viewport, ending the
1599
1649
  list short of the panel with the last row clipped. Two bodies skip it and take the `Drawer`'s
1600
1650
  bare slot: a FULL-BLEED one (a band spanning the panel, a record screen with its own gutters),
@@ -1605,7 +1655,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1605
1655
  edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
1606
1656
  (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
1607
1657
  to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader
1608
- (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned
1658
+ (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody (which takes the
1659
+ **`scrollKey`** seam — a wizard stepping between steps swaps its body in place), a pinned
1609
1660
  ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a
1610
1661
  focused capture / multi-step wizard / a console the user steps INTO; pick Dialog when the
1611
1662
  surface is a card the user can see context around.
@@ -1631,11 +1682,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1631
1682
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1632
1683
  amount, so a scrolling body reaches the panel's real edges while the text stays on the
1633
1684
  same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
1634
- (which own their scroll) are untouched.
1685
+ (which own their scroll) are untouched. It also carries the **scroll seam** every scroller
1686
+ that swaps its body in place takes — keyed on the routed popover's current route, with no prop
1687
+ at call site, and inert in a popover that has no sub-screens.
1635
1688
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
1636
1689
  built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
1637
1690
  `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
1638
- root and screens render conditionally (unmounted when inactive no scroll preservation),
1691
+ root and screens render conditionally (unmounted when inactive, so a screen's own state does
1692
+ not survive the round trip — the panel's SCROLL does: the screens share `PopoverContent`'s one
1693
+ scroller, which takes the ROUTE as its seam key, so a sub-screen opens at the top with its
1694
+ `PopoverNavHeader` in view and the root list's offset returns on the way back),
1639
1695
  `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack`
1640
1696
  (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover; route PATTERNS,
1641
1697
  `params`, and stacked-alive screens are `screen_router`'s job.
@@ -1661,6 +1717,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1661
1717
  content) — it is what `SectionHeadingTitle.info`/`CardHeaderTitle.info` render.
1662
1718
  - **`alert`** — `Alert` (+ `AlertButton`/`AlertOptions`): the blocking confirm —
1663
1719
  `Alert.alert(title, message, [{cancel}, {destructive}])`.
1720
+ - **`overlay_layer`** — `OVERLAY_Z` / `OVERLAY_Z_ABOVE` / `NOTIFICATION_Z` / `SKIP_LINK_Z`: the
1721
+ ONE paint-order table for everything that reaches `document.body` (the rungs are in the table
1722
+ above), and the file that states why open order works. Constants only — there is no layer to
1723
+ render and nothing to claim; an overlay of your own gets its place by mounting its react-native
1724
+ `Modal` only while it is open. Never write one of these numbers as a literal.
1664
1725
  - **`overlay_scope`** — `isOverlayScopeActive` / `pushOverlayScope` / `useOverlayScope`: the
1665
1726
  module-level open-overlay counter every overlay primitive reports into; the host's
1666
1727
  shortcut registry reads it synchronously to floor page-level shortcuts while any overlay
@@ -1715,11 +1776,24 @@ component rather than showing it at zero.
1715
1776
  it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
1716
1777
  register wants. Reach for a real chart the moment someone needs to read a value off it.
1717
1778
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1718
- `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1719
- as the track fits and thins the rest, anchored on the LAST point the newest reading is the
1720
- one a reader looks up, and anchoring there is what keeps the spacing uniform. The first
1721
- point is labelled only when it clears the same distance, so a series whose length does not
1722
- divide evenly drops its opening label rather than crowding the one beside it.
1779
+ `PieChart`: the canonical SVG chart set (no recharts). **A categorical axis prints as many
1780
+ labels as the track fits and thins the rest**`LineChart` and `BarChart`'s vertical
1781
+ orientation both, because it is the axis's rule and not one chart's. It is anchored on the LAST
1782
+ position: the newest reading is the one a reader looks up, and anchoring there is what keeps the
1783
+ spacing uniform; the first position is labelled only when it clears the same distance, so a
1784
+ series whose length does not divide evenly drops its opening label rather than crowding the one
1785
+ beside it. **The BARS never thin** — how many bars a card carries is a question about the data,
1786
+ never about how wide a label happens to be, and an unlabelled bar keeps its slot so the row
1787
+ above stays in line. **What thins is the whole annotation, label AND value together**, because a
1788
+ bar's name and its figure are one reading: a number under an unnamed bar is a quantity of nothing
1789
+ a reader can name, and the surviving annotation is widened across the room its dropped neighbours
1790
+ vacated — room the figure needs as much as the name does. So a DENSE vertical `BarChart` (more
1791
+ bars than `track ÷ 50px`: a 30-day daily series in a 600px card keeps roughly ten) shows the
1792
+ figures only for the bars it labels. When every bar's number has to be readable, that is a
1793
+ `Table` or the horizontal orientation, which gives each entity its own row and its own value
1794
+ column. `BarChart`'s horizontal orientation is one row per entity: **`labelWidth`**
1795
+ sizes the fixed label column (default 80, which fits a date and not a company name), and each
1796
+ item's **`leading`** slot carries that entity's own mark ahead of its name.
1723
1797
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1724
1798
  plain sm tabular count beside it.
1725
1799
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -1741,7 +1815,10 @@ component rather than showing it at zero.
1741
1815
  cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
1742
1816
  drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
1743
1817
  a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
1744
- never the only channel. Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1818
+ never the only channel. **`leading`** is the entity's own mark (`BrandMark`, `Avatar`, a status
1819
+ dot), centred on the label's line box — a row's segment colours belong to the MEASURE, so unlike
1820
+ a legend row this one carries no identity until you give it one, and a chart beside a table over
1821
+ the same entities has to draw them the same way (composition.md § the register's own craft). Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1745
1822
  (one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
1746
1823
  compare.
1747
1824
  - **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
@@ -2094,13 +2171,15 @@ component rather than showing it at zero.
2094
2171
  hatch — input/error untouched). A per-tool failure shows amber with the reason in its expanded
2095
2172
  Error panel; a run-level breaking `error` (outside `parts`) renders as a terminal danger row, with
2096
2173
  an optional `onRetry` Button under it. While it runs the whole feed is ONE row and NO prose: the
2097
- row names the call in flight ("Searching records") and says "Thinking" whenever the
2098
- model is writing rather than calling. The agent's between-call narration is never shown the one
2174
+ row names the call in flight ("Searching records") and says "Thinking" whenever the
2175
+ model is writing rather than calling; an unsettled row BREATHES, label and dot on one rhythm,
2176
+ which is why no live label carries an ellipsis (a row that is parked `awaiting` does not breathe). The agent's between-call narration is never shown — the one
2099
2177
  message needing a response is a bulk-mutation confirmation, and the agent waits there, so the run
2100
2178
  stops and that text becomes the answer. A reply with no tool calls is all answer and still streams.
2101
- At settle the work folds to "{last action}" with the answer below. Expanding is the one
2102
- place live and settled differ: opened mid-run the timeline is capped and self-pinning (220px),
2103
- opened settled it is uncapped. Stays fully open while a call is parked, when a SETTLED run ended on
2179
+ At settle the work folds to "{last action}" with the answer below. Expanding opens the timeline
2180
+ IN FULL, live or settled the run never frames itself, and the HOST'S scroller follows it (a
2181
+ self-imposed window nests a scroller inside the host's own, which in chat means an inverted list
2182
+ inside an inverted list). Stays fully open while a call is parked, when a SETTLED run ended on
2104
2183
  a tool call, and when the work is already one row. `collapseProcess={false}` hands the job back to
2105
2184
  the caller (a surface that already frames the run — it then owns the `FollowScroll` too);
2106
2185
  `summarizeRun(steps)` renames the row.
@@ -2134,7 +2213,8 @@ component rather than showing it at zero.
2134
2213
  the chat message list's inverted mechanism packaged as a wrapper (single-cell inverted list),
2135
2214
  so the follow happens at LAYOUT level: each growth paints already pinned (no flash), scrolling
2136
2215
  up releases it, and short content still reads top-down. Wrap a streaming `AgentRun` with it in
2137
- any bounded container (a dialog, a drawer, a panel — put the height bound on `style`);
2216
+ any bounded container (a dialog, a drawer, a panel — put the height bound on `style`) — but ONLY
2217
+ one you framed yourself with `collapseProcess={false}`, or it nests a scroller in a scroller;
2138
2218
  `AgentProgress`'s expanded panel uses it. Inherits the inverted pattern's quirks (scrollbar
2139
2219
  thumb runs opposite; keyboard paging inverted) — same as chat. A full-page chat list is
2140
2220
  already inverted and doesn't need it.
@@ -2202,7 +2282,14 @@ component rather than showing it at zero.
2202
2282
  `ids` is normally a memo over query rows — a new array every render, which is the
2203
2283
  `Maximum update depth exceeded` loop). `initial` defaults to `accepted` so the operator drops
2204
2284
  exceptions instead of approving eight identical lines; pass `pending` when each change deserves
2205
- its own verdict and gate the commit on `settled`.
2285
+ its own verdict and gate the commit on `settled`. **`initial` also takes a
2286
+ `ReadonlyMap<id, decision>`**, for a set that mixes kinds with different safe defaults —
2287
+ filling a blank arrives `accepted`, overwriting a value a human already set arrives `rejected`,
2288
+ so the destructive half is opt-in; an id the map does not name arrives `accepted`. Build it in a
2289
+ `useMemo` over your own rows: it is a VALUE, so `status` and the groups derive from it together
2290
+ and `status` changes identity whenever a decision does — which is what a screen memoizing its
2291
+ rows on `status` needs. Do not reach for `reject()` in the result handler instead: that writes
2292
+ overrides the operator never made, so `undo` hands the row back `accepted`.
2206
2293
  - **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
2207
2294
  (the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
2208
2295
  the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires
@@ -2231,7 +2318,7 @@ component rather than showing it at zero.
2231
2318
  or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
2232
2319
  description; the answer's value IS the label, the `ask_user_choice` wire shape). Seams:
2233
2320
  `labelForCall` / `renderToolOutput`; no empty-state slot by design (`AgentRun` owns the
2234
- localized "Starting" row). Worked example: `tpl_item_list`'s intake dialog.
2321
+ localized "Starting" row). Worked example: `tpl_item_list`'s intake dialog.
2235
2322
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
2236
2323
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
2237
2324
  agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
@@ -69,6 +69,28 @@ the subject into rows.
69
69
  the same left edge as the title above it and the actions below it. Never hand-pad inside one,
70
70
  and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
71
71
  only the surface knows which it is.
72
+ - **ANY scroller whose body is SWAPPED opens the new content at the top.** Stated as a rule and
73
+ not as a count of the surfaces that happen to have one today: `ModalBody`,
74
+ `DialogScrollArea` and `DrawerScrollArea` each take a `scrollKey` — the content's identity (a
75
+ record id, a step name) — and the kit's routed `Popover` takes the same seam with no prop at
76
+ all, because its ROUTE already is that identity. A scroller you add that swaps its body joins
77
+ the rule rather than being added to a list. `scrollKey={openChild?.id}` is the right shape:
78
+ an ABSENT key names the root content, not an opt-out, so the offset comes back when the child
79
+ closes. Opting out needs no signal — a surface whose key never changes never scrolls. Without it the container keeps the offset it
80
+ was holding for content that no longer exists, so a swapped-in record opens part-way down
81
+ itself with its heading off-screen above — invisible until the first list long enough to
82
+ scroll, which is the same list that made the swap worth having. The key opens unseen content
83
+ at the top and restores the offset when it comes BACK, which is what a React `key` on the
84
+ scroll area cannot do: that rebuilds the container, resetting the parent's place along with
85
+ the child's. Omit it where the body is one thing, and inside a `Screen`-routed dialog, where
86
+ a stacked screen stays mounted and already keeps its own offset.
87
+ - **Overlays paint in the order they were OPENED, and no call site has to know it.** Mount them
88
+ wherever composition wants — a `Dialog` sitting always-mounted beside the `Drawer` whose row
89
+ opens it is the ordinary shape, and it is the one that used to fail: the dialog claimed its DOM
90
+ slot on the app's first paint, the drawer landed after it, and the dialog then rendered
91
+ perfectly and read correctly with every control under the panel DEAD. Whatever opens last is on
92
+ top, both ways round, so a drawer opened from a dialog covers it too. `Alert` and `Tooltip` sit
93
+ above every overlay at any depth — they are ABOUT the surface under them.
72
94
  - **The responsive one is READ, never re-derived — `useDialogGutter()`, no argument.** Inside
73
95
  a `Dialog` that is what the header, the scroll area, the footer and any pane a caller drops
74
96
  in all call. The `Dialog` resolves `small ? 16 : 24` ONCE, off the SCREEN, and publishes it.
@@ -829,7 +851,11 @@ unconditionally and let an empty `value` disable it, rather than revealing it on
829
851
 
830
852
  Pressing a PRIMARY entity row opens the record workspace in a `Drawer` with `onPrev`/`onNext`/
831
853
  `position` ("3/24") over the visible ordering (←/→ arrow keys are built in). Key the drawer body by
832
- record id so per-record state resets on step. Facts are `DetailRow`s; the commit bar is a
854
+ record id so per-record state resets on step. A drawer that instead REPLACES its body with a child
855
+ record — the shape where a row inside the panel opens a second record rather than stacking a
856
+ second drawer — passes that id as `DrawerScrollArea`'s `scrollKey` (`scrollKey={openChild?.id}` —
857
+ the undefined leg is the list's own identity), so the child opens at the top and the list keeps its
858
+ place on the way back. Facts are `DetailRow`s; the commit bar is a
833
859
  `DrawerFooter` (a hairline-topped band pinned to the panel bottom — render it as the LAST child,
834
860
  after the scroll body; actions sit right, a leading `<Text style={{ flex: 1 }}>` hint pushes them
835
861
  there). The open row shows a `selected` highlight (and `marked` for a bulk-ticked row). `Peek` is
@@ -1318,6 +1344,15 @@ colours, same shapes. Re-rendering it as plain text makes the reader translate b
1318
1344
  presentations of one field, and the two drift the first time either side gains a value. This holds
1319
1345
  for every custom-rendered value, not just status.
1320
1346
 
1347
+ **The same rule reaches the CHART beside the table.** One entity, one rendering, everywhere inside
1348
+ one card: if the table's first cell carries the entity's mark (`BrandMark`, `Avatar`, a status
1349
+ dot), the chart's rows carry it too — `StackedBarRow` and `BarChartItem` each take a `leading`
1350
+ slot for exactly that, laid out on the label's own line box. It is worst where the mark carries
1351
+ most (a channel, a platform, a person), and the two ways out that are not the slot are both
1352
+ losses: dropping the mark from the table to match makes *both* halves anonymous, and hand-drawing
1353
+ the chart to get one slot loses the component. A LEGEND row is the exception and needs no slot —
1354
+ its subject is a category, and the colour swatch already is that category's identity.
1355
+
1321
1356
  **Group by what implies a different ACTION, never by a category the reader can already see.**
1322
1357
  Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
1323
1358
  the grouping tells the reader something no column does. Grouping by a value that is already a
@@ -1780,13 +1815,20 @@ complaint it was meant to fix, because a reader who cannot read a label reports
1780
1815
  cluttered rather than as small. Recede with INK, at the same rung. `xs` is for genuine meta (a
1781
1816
  timestamp, a count), not for a label that a person actually has to read.
1782
1817
 
1783
- **A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
1818
+ **A LABEL sharing a row with a flexible VALUE must be told which one HOLDS.** Two items in one
1784
1819
  flex row, and whichever cannot shrink forces the other to. It is invisible wherever the values
1785
1820
  happen to fit, which is usually the width it was built at, and it appears the first time a real
1786
- record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
1787
- whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
1788
- and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
1789
- recognisably a phone number.
1821
+ record carries a long one. Say which one stays whole — `flexShrink: 0` on a fixed-length key, on a
1822
+ figure, on a control and pick by which is still USEFUL truncated: `RC-2026-0…` is unusable,
1823
+ `(555) 384-7…` is still recognisably a phone number.
1824
+
1825
+ The clipping half needs only **`numberOfLines`**, which now brings its own `minWidth: 0` +
1826
+ `flexShrink: 1`: a clamp that cannot shrink cannot clamp, so the prop that says "cut this to fit"
1827
+ is what makes it possible. (Neither platform gave it for free — on web a clamped `Text` is
1828
+ `white-space: nowrap`, so `min-width: auto` floors it at the WHOLE string; on native a `Text` is
1829
+ `flexShrink: 0`. A row of two clamped values laid out at intrinsic width and ran off the frame.)
1830
+ Both are no-ops until a row actually overflows, and a `style` on the call site still wins — which
1831
+ is how a figure keeps holding over its own label.
1790
1832
 
1791
1833
 
1792
1834
 
package/docs/templates.md CHANGED
@@ -450,7 +450,9 @@ The corollaries, each of which a register is routinely missing:
450
450
  DRAFTS; it does not write. Every drafted record then renders in full — all four fields as
451
451
  they will be stored, each editable in place, a `DiffMark` per card, Keep/Drop via
452
452
  `useChangeSet` (`initial: "accepted"` — the operator drops the exceptions rather than
453
- approving six identical records) and ONE commit named for its outcome creates them. The
453
+ approving six identical records; a set that also OVERWRITES values a human set passes a
454
+ `Map<id, decision>` instead, so that half arrives dropped) — and ONE commit named for its outcome
455
+ creates them. The
454
456
  manual form beside it skips all of that and saves direct. Only the DOCUMENT path needs the
455
457
  gate. The receipt still follows the write — it states the outcome, ROUTES, and carries the
456
458
  deterministic checks (a checksum, a count reconciliation).
package/docs/testing.md CHANGED
@@ -46,6 +46,12 @@ Assert two things, not one: that the content appeared, and that it **dismisses**
46
46
  backdrop, so clicking the trigger a second time is often intercepted; click the
47
47
  backdrop or press `Escape`.
48
48
 
49
+ Two overlays open at once stack by the order they were OPENED, so the newest one
50
+ takes the press. If a driver reports `subtree intercepts pointer events` on a
51
+ control of the overlay you just opened, that is a real defect and not this
52
+ anatomy: check with `document.elementFromPoint` at the control's centre, which
53
+ names whatever is actually on top.
54
+
49
55
  ## Custom pointer drag is not `dragTo`
50
56
 
51
57
  The calendar and gantt drags are built on `use_pointer_drag`, which listens for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.1.0",
3
+ "version": "46.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -150,6 +150,7 @@
150
150
  "./popover_nav": "./src/popover_nav.tsx",
151
151
  "./popover": "./src/popover.tsx",
152
152
  "./overlay_scope": "./src/overlay_scope.ts",
153
+ "./overlay_layer": "./src/overlay_layer.ts",
153
154
  "./switcher": "./src/switcher.tsx",
154
155
  "./menu_button": "./src/menu_button.tsx",
155
156
  "./menu_list_item": "./src/menu_list_item.tsx",
@@ -334,7 +335,7 @@
334
335
  "dependencies": {
335
336
  "@lotics/docx": "^0.3.0",
336
337
  "@lotics/markdown-editor": "^0.1.0",
337
- "@lotics/xlsx": "^0.2.0",
338
+ "@lotics/xlsx": "^0.3.0",
338
339
  "ai": "^7.0.30",
339
340
  "mdast-util-from-markdown": "^2.0.3",
340
341
  "mdast-util-gfm-footnote": "^2.1.0",