@botiverse/raft-ui 0.22.1 → 0.23.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,94 @@
1
+ ## [0.23.1](https://github.com/botiverse/raft-design/compare/v0.23.0...v0.23.1) (2026-10-22)
2
+
3
+ ### Fixed
4
+
5
+ - **MessageItemToolbar**: measures its width onto the row (`--mi-toolbar-width`) as the toolbar shows, on the row's `pointerenter` or `focusin`, instead of when the row mounts, so a message row mounts in one commit again and keeps no ResizeObserver of its own. The fade under the toolbar looks the same. ([#738](https://github.com/botiverse/raft-design/pull/738))
6
+ - Why: 0.23.0 kept the toolbar's element in React state and gave every toolbar its own ResizeObserver, and every message row renders a toolbar, so each row committed twice on mount and a long channel held one observer per message; only the toolbar that shows needs its width, so it is now measured then.
7
+ - Affects: per row on mount, 2 commits → 1 and 1 ResizeObserver → 0; while a toolbar shows (hover, focus inside the row, an open popup), one ResizeObserver shared by the page watches it; it lets go of a toolbar once the pointer and focus have left its row with no popup open (after a popup closed elsewhere, the next time any toolbar shows) and is disconnected when it watches none. On mount the row gets `--mi-toolbar-width: 164px` (five buttons' width), replaced by the measured width as the toolbar shows, so a toolbar that ever shows without a pointer or focus event on its row fades five buttons' width until it measures itself: too much for a smaller toolbar rather than too little. A row without a `MessageItemToolbar` still gets no width and masks nothing.
8
+ - Docs: https://raft-design.botiverse.build/components/message-item/#behavior
9
+
10
+ ## [0.23.0](https://github.com/botiverse/raft-design/compare/v0.22.1...v0.23.0) (2026-10-22)
11
+
12
+ ### Design System
13
+
14
+ - **Switch on the region's width**: a layout switches on the width of the region it sits in (a panel, a column, a card), not on the window's. A component that switches its own layout makes itself a container, so it lays out the same in a 360px panel on a wide window as on a phone; it then fills its row and needs a parent with a definite width. Only what the window itself sizes reads the window (the page shell, dialogs, sheets, toasts), and mouse or touch is read with `pointer` and `hover`, never with the width. Every row needs a way to give way: a row of controls that still doesn't fit wraps onto a second line, and a value such as an email truncates last. Whether a part's content fits is measured, not guessed from a width. It applies to DescriptionList, MessageActionCard, MessageImageGallery, MessageVideoPreview, MessageForwardedBundleGallery, Steps, the TasksPanel toolbar, ActivityInboxSelectionToolbar, ListRowMeta, SegmentedControl, SidebarSectionControls, SearchEntityResult and CodeBlockPre. ([#721](https://github.com/botiverse/raft-design/pull/721), [#733](https://github.com/botiverse/raft-design/pull/733))
15
+ - Why: the same component sits in a narrow panel on a wide window and fills a phone, so a layout that asked the window laid itself out for a screen it didn't have (a two-column list in a 360px panel, a step row that wrapped where it should stack); every layout that switched on the window was moved to its own region, or to the input where the switch is about touch.
16
+ - Affects: the switch points listed under Breaking Changes and Changed below.
17
+ - Docs: https://raft-design.botiverse.build/patterns/responsive/#switch-on-the-regions-width
18
+ - **Stacked buttons each take a frame**: when buttons stack full width (a dialog footer whose row doesn't fit, a bottom or top sheet's actions), a ghost button there takes the ink border of `default`, with its hover and press; it goes back to frameless when the row fits again. It applies to Button, DialogFooter, AlertDialogFooter and DrawerActions. ([#727](https://github.com/botiverse/raft-design/pull/727))
19
+ - Why: stretched to the full width, a frameless Cancel read as a line of text under the commit button, not as a button; once buttons stack, each needs its own box.
20
+ - Affects: a stacked ghost button: border `transparent` → `line-strong` on `layer-card`, hover and press `overlay-medium`, as `default`; text sizes `xs` to `xl` only, ghost icon buttons stay frameless.
21
+ - Docs: https://raft-design.botiverse.build/components/dialog/#the-footer
22
+ - **Text selection: its own token**: selected text (`::selection`) is painted with the new `selection` token and keeps its own text color. In light mode it is `brand-soft`, as before; in dark mode it is `brand` over the page at 22%, the same wash as a commented passage. `brand-soft` stays the menu highlight. It applies to MessageItem text. ([#730](https://github.com/botiverse/raft-design/pull/730))
23
+ - Why: in dark mode `brand-soft` left a selection at 1.23:1 against the canvas, so it was hard to see what was selected; a menu row's highlight and a selection need different values in dark mode, so they no longer share one.
24
+ - Affects: dark `selection` `brand-soft` (`oklch(0.5501 0.0999 91.5 / 0.2)`) → `color-mix(in oklch, var(--brand-400) 22%, transparent)`: 1.23:1 → 1.77:1 against the canvas, the selected text 7.7:1 (APCA Lc 80). Light mode is unchanged (1.14:1 against the canvas, the text 16.5:1, Lc 96).
25
+ - Docs: https://raft-design.botiverse.build/foundations/color/#text-selection
26
+
27
+ ### Breaking Changes
28
+
29
+ - **MessageItem**: the header keeps no room for the hover toolbar, and `--mi-toolbar-reserve` is no longer read. At rest the sender line has the full width; while the toolbar shows (hover, focus inside the row, an open menu), the end of a sender line long enough to reach the corner fades out under it. `MessageItemToolbar` measures its own width onto the row as `--mi-toolbar-width`, so a toolbar with more or fewer buttons masks exactly its width. ([#726](https://github.com/botiverse/raft-design/pull/726))
30
+ - Why: keeping room for the toolbar cut the name to a few letters in a narrow column all the time, to protect a corner that is covered only while the pointer is on that message; the line now keeps its full width and nothing reflows on hover.
31
+ - Affects: with a mouse, header end padding `--mi-toolbar-reserve` (default 7rem) → 0. While the toolbar shows, the header is masked from 4px left of the toolbar's left edge to its end, after a 12px fade (`--mi-header-mask`); a row without a `MessageItemToolbar` masks nothing. The time's tooltip, when it fades out under the toolbar, can be pointed at once the pointer leaves the toolbar, and stays in the keyboard order.
32
+ - Migrate: remove `--mi-toolbar-reserve` wherever you set it; it has no effect. Render the toolbar with `MessageItemToolbar` inside the `MessageItem` row, which writes `--mi-toolbar-width`; a hover toolbar of your own gets no fade unless it sets `--mi-toolbar-width` (its width in px) on the row (`[data-message-item]`).
33
+ - Docs: https://raft-design.botiverse.build/components/message-item/#behavior
34
+ - **DescriptionList** `direction="horizontal"`, **MessageActionCardContent**, **MessageImageGallery**, **MessageForwardedBundleGallery**, **Steps** (without `vertical`) and **TasksPanelRoot** are size containers (`container-type: inline-size`) and switch their layout on their own width instead of the window's; the list, the galleries and the step row fill their row (`width: 100%`). ([#721](https://github.com/botiverse/raft-design/pull/721))
35
+ - Why: see Switch on the region's width, above.
36
+ - Affects: DescriptionList stacks below 24rem (384px) of its own width (was below a 640px window), the value right under its label (its 32px minimum row height dropped) and 10px between pairs (the 2px row gap plus 8px; was 2px). MessageActionCard properties stack below 24rem of the card's content (was below a 640px window) with the same spacing, the value's 6px padding above and below dropped; a card at its 440px cap keeps two columns. MessageImageGallery: at most `min(360px, 100%)` of its column (was `min(360px, 100vw − 112px)` below a 768px window); rows 8rem (7rem with three images) below 360px of the gallery's own width and 9rem (8rem) at it (was by a 640px window); three images stay two across and one under them. MessageForwardedBundleGallery: a row of three is three across from 360px of the gallery's own width (was from a 768px window) and two across below; its rows grow as above from 360px (was from a 640px window). Steps: below 40rem (640px) of the row's own width each step stacks its text under its marker (was below a 640px window), and from 40rem a row too long for its width wraps. TasksPanelToolbar: New Task and the view switch on one line and the filters on the next below 28rem (448px) of the panel's width (was below a 768px window), one wrapping row from 28rem; toolbar and viewport sides 16px below 28rem, 20px from it (was by a 768px window). The same window can now show a different layout: a narrow panel on a desktop takes the narrow layout, and a wide region on a small window keeps the wide one.
37
+ - Migrate: put each of these in a parent with a definite width: a block, a stretched flex or grid item, or anything with a width set. In a parent that shrinks to fit its content (`inline-block`, `w-fit` or `fit-content`, an absolutely positioned box or a floating layer with no width, a flex item with `flex-basis: auto` and no width) a size container takes no width from what it holds and collapses, its text one word to a line. Keep `TasksPanelToolbar` and `TasksPanelViewport` inside `TasksPanelRoot`, and `MessageActionCardProperties` inside `MessageActionCardContent`: outside them there is no container, so the tasks toolbar keeps its two-line layout with 20px sides and the properties stay in two columns at every width. Remove window breakpoint overrides (`sm:`, `md:`, `max-sm:`) you added to adjust these layouts, and check them in your narrowest panels.
38
+ - Docs: https://raft-design.botiverse.build/patterns/responsive/#switch-on-the-regions-width
39
+
40
+ ### Added
41
+
42
+ - **SegmentedControl** `iconsOnlyBelow="auto"`: the segments show only their icons exactly when their labelled segments don't fit the width the control is given, and their labels again once they fit. It decides before the first frame paints, and again when the room, a label, a count, the selection or a web font changes, always against the labelled layout, so it doesn't flicker at the boundary. `iconsOnlyBelow` also takes any length in `px`, `rem` or `em` (`"18.5rem"`, `"296px"`), measured against the nearest `@container` (the control's parent when there is none), beside the presets `xs` to `2xl`, which stay CSS container queries. With `auto` or a length the track carries `data-icons-only` while it shows only icons. The presets' type is exported as `SegmentedControlIconsOnlyPreset`. ([#733](https://github.com/botiverse/raft-design/pull/733))
43
+ - Why: a width picked for one language, one count or one font was wrong for the others, and the room a control is given changes with what sits beside it; `auto` measures the labels against that room instead.
44
+ - Affects: with `auto`, the control is a shrinkable flex item (`min-w-0`); leave it free to shrink in its row (don't make it `shrink-0`). In development raft-ui warns when a segment has no icon, or when `iconsOnlyBelow` is neither a preset, `auto` nor a `px`, `rem` or `em` length (the segments then keep their labels).
45
+ - Docs: https://raft-design.botiverse.build/components/segmented-control/#behavior
46
+ - **`selection`** token: `--selection` in `foundation.css` and `tokens.json`, and the Tailwind color `selection` (`selection:bg-selection`). ([#730](https://github.com/botiverse/raft-design/pull/730))
47
+ - Why: see Text selection: its own token, above.
48
+ - Affects: light `var(--brand-soft)`; dark `color-mix(in oklch, var(--brand-400) 22%, transparent)`. Paint your own `::selection` with it in place of `brand-soft`.
49
+ - Docs: https://raft-design.botiverse.build/foundations/color/#text-selection
50
+
51
+ ### Changed
52
+
53
+ - **SegmentedControl**: the track may shrink to its row. When the row is narrower than the track, its neighbors have given way and the segments can't show only their icons (or no `iconsOnlyBelow` is set), the segments scroll sideways inside the track, the checked one kept in view, instead of spilling onto the next control; the track sets `data-overflowing` while they do. ([#721](https://github.com/botiverse/raft-design/pull/721))
54
+ - Why: the track never shrank below its segments, so in a narrow row it ran over its neighbor; scrolling inside it is the last way it gives way.
55
+ - Affects: track `shrink-0` → `min-w-0 max-w-full`; while overflowing, `overflow-x: auto` with no scrollbar. Segments still never shrink. A track you made `shrink-0` yourself still never shrinks.
56
+ - Docs: https://raft-design.botiverse.build/components/segmented-control/#behavior
57
+ - **SidebarSectionControls** and **SearchEntityResult**: decided by the input, not the window's width. The section controls show at rest wherever there is no fine pointer (a touch screen, a tablet at any width) and on hover, focus or an open menu with a mouse; a search result row is at least 64px tall on a touch screen at any width. ([#721](https://github.com/botiverse/raft-design/pull/721))
58
+ - Why: a tablet is touch at 1024px and a narrow desktop window still has a mouse, so the width was the wrong question.
59
+ - Affects: section controls at rest: shown below a 768px window → shown without `pointer: fine`; a narrow desktop window with a mouse now shows them on hover only. Search result `min-height: 64px`: below a 768px window → `pointer: coarse`.
60
+ - Docs: https://raft-design.botiverse.build/patterns/responsive/#touch-screens
61
+ - **ActivityInboxSelectionToolbar**: one 44px row while its controls fit, and they wrap onto a second line in a narrow column, whatever the window. ([#721](https://github.com/botiverse/raft-design/pull/721))
62
+ - Why: it wrapped only below a 768px window, so in a narrow column on a wide window its controls overflowed.
63
+ - Affects: `flex-wrap` at every width (was below a 768px window only); padding 6px above and 5px below (was 8px below a 768px window, 0 with a fixed 44px height from it).
64
+ - Docs: https://raft-design.botiverse.build/components/activity-inbox/
65
+ - **ListRowMeta**: stays whole while the title gives way first, then truncates when there is still no room, so a long value (an email beside a button in a 320px row) is cut instead of running under the button. ([#721](https://github.com/botiverse/raft-design/pull/721))
66
+ - Why: the meta never shrank, so a long value overflowed the row.
67
+ - Affects: `shrink-0` → `min-w-0 truncate`.
68
+ - Docs: https://raft-design.botiverse.build/components/list-row/#content
69
+ - **MessageVideoPreview**: at most `min(360px, 100%)` of its column. ([#721](https://github.com/botiverse/raft-design/pull/721))
70
+ - Why: its cap was the window less a guess at the message's chrome, which was wrong in a thread beside a conversation.
71
+ - Affects: `max-width` `min(360px, 100vw − 112px)` → `min(360px, 100%)`.
72
+ - Docs: https://raft-design.botiverse.build/components/message-attachment/#rules
73
+ - **Button** `variant="ghost"`, so **DialogFooter**, **AlertDialogFooter** (`AlertDialogCancel`) and **DrawerActions**: a ghost text button inside an element with `data-stacked` takes the ink frame of `default`. Dialog and alert dialog footers set `data-stacked` only while their buttons stack; `DrawerActions` sets it in a bottom or top sheet, where its buttons are always stacked, and not in a drawer from a side. Keep writing Cancel as `ghost`. ([#727](https://github.com/botiverse/raft-design/pull/727))
74
+ - Why: see Stacked buttons each take a frame, above.
75
+ - Affects: as above; a disabled ghost button keeps the disabled fill, and ghost icon buttons stay frameless. A stacked footer's buttons have no transition until the row has been measured and painted once (`data-measured`), so the frame doesn't fade in as a dialog opens.
76
+ - Docs: https://raft-design.botiverse.build/components/button/#which-color
77
+ - **MessageItem** text: selected text uses the `selection` token, so in dark mode a selection is deeper; light mode is unchanged. ([#730](https://github.com/botiverse/raft-design/pull/730))
78
+ - Why: see Text selection: its own token, above.
79
+ - Affects: `selection:bg-brand-soft` → `selection:bg-selection`.
80
+ - Docs: https://raft-design.botiverse.build/foundations/color/#text-selection
81
+
82
+ ### Fixed
83
+
84
+ - **TaskChip** (default and `size="sm"`): the chip's baseline is its text's, so its words sit on the line of the text beside it, such as a DescriptionList label; the status pixel and icons stay centered in the chip. ([#732](https://github.com/botiverse/raft-design/pull/732))
85
+ - Why: a centered flex box takes its baseline from its first item, so a chip that led with its status pixel sat on the pixel's bottom edge and its words stood a pixel off the label beside them.
86
+ - Affects: text laid out on the baseline inside the chip with 3px above and below (default, 22px) and 2px (`sm`, 20px), marks `self-center`; heights unchanged. `variant="inline"` is unchanged.
87
+ - Docs: https://raft-design.botiverse.build/foundations/alignment/#text-of-different-sizes-on-one-line
88
+ - **CodeBlockPre**: measures again whenever the `pre` resizes or its code changes (highlighting that arrives late, code that streams in), so a line that grows too long becomes the keyboard-reachable region (`tabindex="0"`, `role="region"`) it should be. ([#721](https://github.com/botiverse/raft-design/pull/721))
89
+ - Why: it measured once when it mounted, so code that changed later kept the first answer.
90
+ - Docs: https://raft-design.botiverse.build/components/code-block/#accessibility
91
+
1
92
  ## [0.22.1](https://github.com/botiverse/raft-design/compare/v0.22.0...v0.22.1) (2026-10-22)
2
93
 
3
94
  ### Design System
@@ -296,18 +296,23 @@
296
296
  }
297
297
  }
298
298
 
299
- @media (width >= 40rem) {
300
- .r-steps--horizontal {
301
- flex-wrap: wrap;
302
- row-gap: 8px;
303
- }
299
+ /* A row of steps is its own container, so it switches on its own width, not
300
+ the window's: a dialog or a 400px panel on a wide window is as narrow as a
301
+ phone (design/patterns/responsive.md#switch-on-the-regions-width). Being a
302
+ container, it fills its row. A row too long for its width wraps its steps
303
+ onto a second line. */
304
+ .r-steps--horizontal {
305
+ container-type: inline-size;
306
+ flex-wrap: wrap;
307
+ row-gap: 8px;
308
+ inline-size: 100%;
304
309
  }
305
310
 
306
- /* Narrow screens: a row of titles beside their markers overflows a phone, so
307
- the steps share the width and each stacks its text under the marker, the
308
- titles wrapping. The marker and the connector share the top line; the text
309
- spans the step below. */
310
- @media (width < 40rem) {
311
+ /* A narrow row (below 40rem of its own width): the titles beside their
312
+ markers would overflow it, so the steps share the width and each stacks its
313
+ text under the marker, the titles wrapping. The marker and the connector
314
+ share the top line; the text spans the step below. */
315
+ @container (width < 40rem) {
311
316
  .r-steps--horizontal > .r-step {
312
317
  --r-steps-connector-gap: 6px;
313
318
  --r-steps-connector-min-width: 8px;
@@ -287,6 +287,10 @@
287
287
  empties it while the root carries data-input-modality="pointer", which
288
288
  installInputModality sets after a click or a tap. */
289
289
  --focus-ring: 0 0 0 2px oklch(1 0 0), 0 0 0 4px oklch(0.1813 0.005 17.62);
290
+ /* Selected text (::selection). brand-soft in light mode; in dark mode brand
291
+ over the page at 22%, the wash of a commented passage, because brand-soft
292
+ left a selection almost the same as the page. */
293
+ --selection: var(--brand-soft);
290
294
  --field-font-size: 16px;
291
295
  --field-font-weight: 400;
292
296
  --field-line-height: 24px;
@@ -415,6 +419,7 @@
415
419
  0 0 0 1px oklch(0.9843 0.004 106.47 / 0.078), 0 16px 40px -12px oklch(0 0 0 / 0.8);
416
420
  --elevation-sticker: 4px 4px 0 oklch(0 0 0);
417
421
  --focus-ring: 0 0 0 2px oklch(0.2213 0.0038 106.71), 0 0 0 4px oklch(0.9211 0.004 106.48);
422
+ --selection: color-mix(in oklch, var(--brand-400) 22%, transparent);
418
423
  --sticker-edge: oklch(0.9843 0.004 106.47 / 0.349);
419
424
  }
420
425
 
@@ -497,6 +502,7 @@
497
502
  0 0 0 1px oklch(0.9843 0.004 106.47 / 0.078), 0 16px 40px -12px oklch(0 0 0 / 0.8);
498
503
  --elevation-sticker: 4px 4px 0 oklch(0 0 0);
499
504
  --focus-ring: 0 0 0 2px oklch(0.2213 0.0038 106.71), 0 0 0 4px oklch(0.9211 0.004 106.48);
505
+ --selection: color-mix(in oklch, var(--brand-400) 22%, transparent);
500
506
  --sticker-edge: oklch(0.9843 0.004 106.47 / 0.349);
501
507
  }
502
508
  }
@@ -56,15 +56,22 @@ const headEdges = ["px-5 max-md:px-4", ...headEdgePull];
56
56
  * as the arithmetic so it follows the control tokens wherever they switch.
57
57
  */
58
58
  const controlIconInset = "[--icon-inset:calc((var(--control-height)-var(--control-icon-size))/2)]";
59
+ //#endregion
60
+ //#region src/lib/stacked-actions.ts
59
61
  /**
60
62
  * The row of buttons while `data-stacked` is set: a full-width column. A
61
63
  * button alone in a wrapper (a tooltip's trigger) fills the width too.
64
+ *
65
+ * Until the row has been measured and painted once (`data-measured`), its
66
+ * buttons have no transition. The first measure lands after the buttons were
67
+ * styled as a row, so a stacked ghost Cancel would otherwise fade its frame in
68
+ * as the dialog opens; the first painted frame must already be the stack.
62
69
  */
63
70
  const stackedActions = [
64
71
  "data-stacked:flex-col-reverse",
65
72
  "data-stacked:items-stretch",
66
73
  "data-stacked:*:data-[slot=button]:w-full data-stacked:[&>*>[data-slot=button]:only-child]:w-full",
67
- ...[].map((value) => `data-stacked:${value}`)
74
+ "not-data-measured:**:data-[slot=button]:transition-none"
68
75
  ];
69
76
  function assignRef$2(ref, value) {
70
77
  if (typeof ref === "function") ref(value);
@@ -117,11 +124,16 @@ function useStackedActions(ref, inParent = false) {
117
124
  useLayoutEffect(() => {
118
125
  const frame = inParent ? row?.parentElement : row;
119
126
  if (!row || !frame || typeof ResizeObserver === "undefined") return;
127
+ let raf = 0;
128
+ let settle = 0;
120
129
  const measure = () => {
121
130
  const overflows = rowOverflows(row, frame);
122
- if (overflows !== void 0) setStacked(overflows);
131
+ if (overflows === void 0) return;
132
+ setStacked(overflows);
133
+ if (!settle && !row.hasAttribute("data-measured")) settle = requestAnimationFrame(() => {
134
+ settle = requestAnimationFrame(() => row.setAttribute("data-measured", ""));
135
+ });
123
136
  };
124
- let raf = 0;
125
137
  const schedule = () => {
126
138
  cancelAnimationFrame(raf);
127
139
  raf = requestAnimationFrame(measure);
@@ -146,6 +158,7 @@ function useStackedActions(ref, inParent = false) {
146
158
  return () => {
147
159
  live = false;
148
160
  cancelAnimationFrame(raf);
161
+ cancelAnimationFrame(settle);
149
162
  resize.disconnect();
150
163
  mutation.disconnect();
151
164
  fonts?.removeEventListener("loadingdone", schedule);
@@ -446,6 +459,21 @@ const buttonVariants = tv$1({
446
459
  size: "md"
447
460
  },
448
461
  compoundVariants: [
462
+ {
463
+ variant: "ghost",
464
+ size: [
465
+ "xs",
466
+ "sm",
467
+ "md",
468
+ "lg",
469
+ "xl"
470
+ ],
471
+ class: { root: [
472
+ "in-data-stacked:border-line-strong in-data-stacked:bg-layer-card",
473
+ "in-data-stacked:hover:bg-layer-card in-data-stacked:active:bg-layer-card",
474
+ "in-data-stacked:hover:overlay-medium in-data-stacked:active:overlay-medium"
475
+ ] }
476
+ },
449
477
  {
450
478
  variant: "ghost",
451
479
  size: [
@@ -1602,7 +1630,7 @@ const listRow = tv$1({
1602
1630
  "group-data-[tone=unread]/list-row:font-bold"
1603
1631
  ],
1604
1632
  description: ["min-w-0 flex-1 truncate", "text-callout text-(--list-row-ink-2)"],
1605
- meta: ["shrink-0", "font-mono text-mono-meta font-normal tabular-nums text-(--list-row-ink-3)"],
1633
+ meta: ["min-w-0 truncate", "font-mono text-mono-meta font-normal tabular-nums text-(--list-row-ink-3)"],
1606
1634
  value: ["max-w-[45%] shrink-0 truncate", "text-subhead text-(--list-row-ink-3)"],
1607
1635
  chevron: ["flex shrink-0 items-center justify-center", "text-(--list-row-ink-3) [&>svg]:size-5 [&>svg]:shrink-0"]
1608
1636
  },
@@ -3262,7 +3290,7 @@ const targets = /* @__PURE__ */ new Set();
3262
3290
  const byElement = /* @__PURE__ */ new WeakMap();
3263
3291
  const pending = /* @__PURE__ */ new Set();
3264
3292
  let frame = 0;
3265
- let resizeObserver;
3293
+ let resizeObserver$1;
3266
3294
  let mutationObserver;
3267
3295
  let fontsListening = false;
3268
3296
  function flush() {
@@ -3294,7 +3322,7 @@ function ownerOf(node) {
3294
3322
  }
3295
3323
  }
3296
3324
  function observers(view) {
3297
- if (!resizeObserver && "ResizeObserver" in view) resizeObserver = new view.ResizeObserver((entries) => {
3325
+ if (!resizeObserver$1 && "ResizeObserver" in view) resizeObserver$1 = new view.ResizeObserver((entries) => {
3298
3326
  for (const entry of entries) {
3299
3327
  const target = byElement.get(entry.target);
3300
3328
  if (target) scheduleMeasure(target);
@@ -3325,7 +3353,7 @@ function observeTruncation(target) {
3325
3353
  targets.add(target);
3326
3354
  for (const element of observed) {
3327
3355
  byElement.set(element, target);
3328
- resizeObserver?.observe(element);
3356
+ resizeObserver$1?.observe(element);
3329
3357
  }
3330
3358
  const watched = target.watch();
3331
3359
  if (watched) {
@@ -3342,7 +3370,7 @@ function observeTruncation(target) {
3342
3370
  pending.delete(target);
3343
3371
  for (const element of observed) {
3344
3372
  if (byElement.get(element) === target) byElement.delete(element);
3345
- resizeObserver?.unobserve(element);
3373
+ resizeObserver$1?.unobserve(element);
3346
3374
  }
3347
3375
  if (watched && byElement.get(watched) === target) byElement.delete(watched);
3348
3376
  if (targets.size === 0) {
@@ -3691,6 +3719,9 @@ const messageItem = tv$1({
3691
3719
  "group/message relative flex h-fit gap-3",
3692
3720
  "hover:[--mi-toolbar-opacity:1] hover:[--mi-toolbar-pe:auto] focus-within:[--mi-toolbar-opacity:1] focus-within:[--mi-toolbar-pe:auto]",
3693
3721
  "has-data-popup-open:[--mi-toolbar-opacity:1] has-data-popup-open:[--mi-toolbar-pe:auto]",
3722
+ "hover:[--mi-header-mask:linear-gradient(to_left,transparent_calc(var(--mi-toolbar-width)+8px),black_calc(var(--mi-toolbar-width)+20px))]",
3723
+ "focus-within:[--mi-header-mask:linear-gradient(to_left,transparent_calc(var(--mi-toolbar-width)+8px),black_calc(var(--mi-toolbar-width)+20px))]",
3724
+ "has-data-popup-open:[--mi-header-mask:linear-gradient(to_left,transparent_calc(var(--mi-toolbar-width)+8px),black_calc(var(--mi-toolbar-width)+20px))]",
3694
3725
  "min-h-0 px-2 py-timeline-row border-t-(length:--spacing-timeline-turn) border-transparent bg-clip-padding",
3695
3726
  "hover:[--mi-ct-color:var(--foreground-hint)] has-data-popup-open:[--mi-ct-color:var(--foreground-hint)]",
3696
3727
  "bg-(--mi-row-fill) [--mi-row-fill:var(--located-fill)]",
@@ -3715,7 +3746,7 @@ const messageItem = tv$1({
3715
3746
  content: ["min-w-0 flex-1", "text-body text-foreground"],
3716
3747
  header: [
3717
3748
  "mb-px flex min-w-0 items-baseline gap-1.5 overflow-hidden",
3718
- "pointer-fine:pe-(--mi-toolbar-reserve,7rem)",
3749
+ "mask-(--mi-header-mask,none)",
3719
3750
  "has-data-[slot=message-item-delegated-by]:[--mi-owner-basis:3rem] has-data-[slot=message-item-delegated-by]:[--mi-owner-flex:1_0]"
3720
3751
  ],
3721
3752
  senderGroup: [
@@ -3760,7 +3791,7 @@ const messageItem = tv$1({
3760
3791
  "wrap-break-word select-text",
3761
3792
  "**:select-text",
3762
3793
  "text-body text-foreground",
3763
- "selection:bg-brand-soft"
3794
+ "selection:bg-selection"
3764
3795
  ],
3765
3796
  searchMatch: searchHit,
3766
3797
  embedded: ["min-w-0", "mt-2"],
@@ -4075,12 +4106,113 @@ function MessageItemDelegatedAvatar({ className, deleted, ...props }) {
4075
4106
  }
4076
4107
  //#endregion
4077
4108
  //#region src/components/message-item/message-item-toolbar.tsx
4078
- function MessageItemToolbar({ className, render, ...props }) {
4109
+ const TOOLBAR_WIDTH = "--mi-toolbar-width";
4110
+ /**
4111
+ * Five buttons' width (5 × 28px, 4 × 4px apart, 4px from the frame each
4112
+ * side): what the row fades until the toolbar has measured itself.
4113
+ */
4114
+ const FALLBACK_WIDTH = "164px";
4115
+ /**
4116
+ * Toolbars watched while their row shows them, each with its row, and the one
4117
+ * ResizeObserver that watches them: it exists only while a toolbar shows.
4118
+ */
4119
+ const watched = /* @__PURE__ */ new Map();
4120
+ let resizeObserver = null;
4121
+ function writeWidth(toolbar, row) {
4122
+ row.style.setProperty(TOOLBAR_WIDTH, `${toolbar.offsetWidth}px`);
4123
+ }
4124
+ /** The recipe's has-data-popup-open / data-popup-open on the row. */
4125
+ function hasOpenPopup(row) {
4126
+ return row.hasAttribute("data-popup-open") || row.querySelector("[data-popup-open]") !== null;
4127
+ }
4128
+ function resized(entries) {
4129
+ for (const entry of entries) {
4130
+ const target = entry.target;
4131
+ const row = watched.get(target);
4132
+ if (row) writeWidth(target, row);
4133
+ }
4134
+ }
4135
+ function unwatch(toolbar) {
4136
+ if (!watched.delete(toolbar)) return;
4137
+ resizeObserver?.unobserve(toolbar);
4138
+ if (watched.size > 0) return;
4139
+ resizeObserver?.disconnect();
4140
+ resizeObserver = null;
4141
+ }
4142
+ function watch(toolbar, row) {
4143
+ if (watched.has(toolbar)) return;
4144
+ writeWidth(toolbar, row);
4145
+ if (typeof ResizeObserver === "undefined") return;
4146
+ for (const [other, otherRow] of watched) if (!otherRow.matches(":hover, :focus-within") && !hasOpenPopup(otherRow)) unwatch(other);
4147
+ resizeObserver ??= new ResizeObserver(resized);
4148
+ watched.set(toolbar, row);
4149
+ resizeObserver.observe(toolbar);
4150
+ }
4151
+ /**
4152
+ * Tells the row how wide the toolbar is (`--mi-toolbar-width` on the row), so
4153
+ * the header can fade out its end under the toolbar while it shows: the
4154
+ * header keeps no room for the toolbar (message-item.md, "The hover
4155
+ * toolbar"), and the toolbar's card only reaches part of the sender line.
4156
+ * A toolbar with more or fewer actions covers exactly its own width; nothing
4157
+ * on the row moves.
4158
+ *
4159
+ * Measured when the toolbar is about to show, not when it mounts: every row
4160
+ * of a timeline renders a toolbar, and the width matters only on the row the
4161
+ * pointer or focus is on. So mounting costs no React state (one commit per
4162
+ * row) and no ResizeObserver per row. The row's `pointerenter` and `focusin`
4163
+ * write the width in the input event's own task, before the frame that
4164
+ * applies the row's :hover / :focus-within styles, so the first frame that
4165
+ * shows the toolbar already masks its width; the toolbar is laid out at rest
4166
+ * (only its opacity is 0), so its `offsetWidth` is right then. A popup opens
4167
+ * from the toolbar only after a hover or a focus, so it finds the width
4168
+ * written. While the row shows the toolbar, one ResizeObserver shared by all
4169
+ * rows watches it, so a button added while it shows re-measures; it lets go
4170
+ * when the pointer and focus have left the row and no popup is open, and is
4171
+ * disconnected once no toolbar shows.
4172
+ *
4173
+ * On attach the row gets five buttons' width (164px) as a fallback: a plain
4174
+ * DOM write, no commit and no read, so a show that ever came without a
4175
+ * pointerenter or focusin (a row that appears under a resting pointer) fades
4176
+ * too much rather than leaving letters beside the card. Rows without a
4177
+ * toolbar never get the property, so their header's gradient stays invalid
4178
+ * and masks nothing.
4179
+ *
4180
+ * A stable callback ref with a cleanup (React 19), merged with the caller's
4181
+ * ref by `useRender`.
4182
+ */
4183
+ function measureWidthOnShow(toolbar) {
4184
+ const row = toolbar?.closest("[data-message-item]");
4185
+ if (!toolbar || !row) return void 0;
4186
+ const show = () => watch(toolbar, row);
4187
+ const pointerLeft = () => {
4188
+ if (!row.matches(":focus-within") && !hasOpenPopup(row)) unwatch(toolbar);
4189
+ };
4190
+ const focusLeft = (event) => {
4191
+ const next = event.relatedTarget instanceof Node ? event.relatedTarget : null;
4192
+ if (row.contains(next) || row.matches(":hover") || hasOpenPopup(row)) return;
4193
+ unwatch(toolbar);
4194
+ };
4195
+ row.style.setProperty(TOOLBAR_WIDTH, FALLBACK_WIDTH);
4196
+ row.addEventListener("pointerenter", show);
4197
+ row.addEventListener("focusin", show);
4198
+ row.addEventListener("pointerleave", pointerLeft);
4199
+ row.addEventListener("focusout", focusLeft);
4200
+ return () => {
4201
+ row.removeEventListener("pointerenter", show);
4202
+ row.removeEventListener("focusin", show);
4203
+ row.removeEventListener("pointerleave", pointerLeft);
4204
+ row.removeEventListener("focusout", focusLeft);
4205
+ unwatch(toolbar);
4206
+ row.style.removeProperty(TOOLBAR_WIDTH);
4207
+ };
4208
+ }
4209
+ function MessageItemToolbar({ className, render, ref, ...props }) {
4079
4210
  const { toolbar } = messageItem();
4080
4211
  const defaultProps = { className: toolbar({ className }) };
4081
4212
  return useRender({
4082
4213
  defaultTagName: "div",
4083
4214
  render,
4215
+ ref: ref ? [ref, measureWidthOnShow] : measureWidthOnShow,
4084
4216
  props: mergeProps(defaultProps, props),
4085
4217
  state: { slot: "message-item-toolbar" }
4086
4218
  });
@@ -1368,7 +1368,7 @@ declare function MessageItemDelegatedAvatar({ className, deleted, ...props }: Me
1368
1368
  //#endregion
1369
1369
  //#region src/components/message-item/message-item-toolbar.d.ts
1370
1370
  type MessageItemToolbarProps = useRender.ComponentProps<"div">;
1371
- declare function MessageItemToolbar({ className, render, ...props }: MessageItemToolbarProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
1371
+ declare function MessageItemToolbar({ className, render, ref, ...props }: MessageItemToolbarProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
1372
1372
  type MessageItemToolbarButtonProps = useRender.ComponentProps<"button"> & {
1373
1373
  active?: boolean;
1374
1374
  };