@botiverse/raft-ui 0.55.0 → 0.56.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/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.56.0](https://github.com/botiverse/raft-design/compare/v0.55.0...v0.56.0) (2026-10-31)
2
+
3
+ ### Changed
4
+
5
+ - **Sidebar section header:** its controls rest in the title's hint color and turn body color only on hover, focus or with their menu open; the controls end on the header's line, and the title starts where the rows' content starts (stdrc, 2026-10-31). ([#922](https://github.com/botiverse/raft-design/pull/922))
6
+
7
+ ### Fixed
8
+
9
+ - **Popups under window controls stay on their trigger.** The frame insets (macOS traffic lights, caption buttons) are now a collision boundary instead of collision padding, so Base UI's shift limiter no longer pushes a popup with an arrow off its trigger: a top-bar tooltip in the installed web app on macOS sat about 69px right of its icon, 38px on Windows and Linux desktop. Popups portaled into a container of their own keep their padding and their clipping ancestors. Tooltip, Popover, PreviewCard, Select, menus, Combobox and NotificationCenter share one hook. ([#927](https://github.com/botiverse/raft-design/pull/927))
10
+ - **MessageItem's Saved marker** is 24px with footnote text, like the reactions and badges beside it, so it no longer reads as a lighter grey. ([#923](https://github.com/botiverse/raft-design/pull/923))
11
+
12
+ ### Design System
13
+
14
+ - **Window-control insets bound a popup; they don't pad it** (Page Structure › Frame insets). ([#927](https://github.com/botiverse/raft-design/pull/927))
15
+ - Docs: https://raft-design.botiverse.build/patterns/page-structure/
16
+ - **Panel header actions:** shown buttons, then ⋯, then Close ([#920](https://github.com/botiverse/raft-design/pull/920)); **Navigation:** a listed item opens in the side panel, Raft has no breadcrumbs ([#921](https://github.com/botiverse/raft-design/pull/921)).
17
+
1
18
  ## [0.55.0](https://github.com/botiverse/raft-design/compare/v0.54.0...v0.55.0) (2026-10-31)
2
19
 
3
20
  ### Added
@@ -1,7 +1,7 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
3
  import { Button } from "../button/button.mjs";
4
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
4
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
5
5
  import { useOverflowTitle } from "../../lib/use-overflow-title.mjs";
6
6
  import { chipRemoveBase } from "../../lib/chip-remove.mjs";
7
7
  import { Check, X } from "lucide-react";
@@ -283,10 +283,10 @@ function ComboboxSeparator({ className, ...props }) {
283
283
  ...props
284
284
  });
285
285
  }
286
- function ComboboxFramePositioner({ collisionPadding, container, ...props }) {
287
- const padding = usePopupCollisionPadding(collisionPadding, 8, container);
286
+ function ComboboxFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
287
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 8, container);
288
288
  return /* @__PURE__ */ jsx(Combobox.Positioner, {
289
- collisionPadding: padding,
289
+ ...collision,
290
290
  ...props
291
291
  });
292
292
  }
@@ -1,6 +1,6 @@
1
1
  import { useTheme } from "../../lib/theme/use-theme.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
3
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
4
4
  import { useOverflowTitle } from "../../lib/use-overflow-title.mjs";
5
5
  import { usePhoneMedia } from "../../lib/phone-media.mjs";
6
6
  import { roomBelowIsShort } from "./combobox-select.list-side.mjs";
@@ -65,10 +65,10 @@ const PHONE_LIST_COLLISION = {
65
65
  align: "shift",
66
66
  fallbackAxisSide: "none"
67
67
  };
68
- function ComboboxSelectFramePositioner({ collisionPadding, container, ...props }) {
69
- const padding = usePopupCollisionPadding(collisionPadding, 8, container);
68
+ function ComboboxSelectFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
69
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 8, container);
70
70
  return /* @__PURE__ */ jsx(Combobox.Positioner, {
71
- collisionPadding: padding,
71
+ ...collision,
72
72
  ...props
73
73
  });
74
74
  }
@@ -1,6 +1,6 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
3
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
4
4
  import { Kbd } from "../kbd/kbd.mjs";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { ContextMenu } from "@base-ui/react";
@@ -83,10 +83,10 @@ function ContextMenuPortal(props) {
83
83
  ...useThemeScopeProps(props)
84
84
  });
85
85
  }
86
- function ContextMenuFramePositioner({ collisionPadding, container, ...props }) {
87
- const padding = usePopupCollisionPadding(collisionPadding, 8, container);
86
+ function ContextMenuFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
87
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 8, container);
88
88
  return /* @__PURE__ */ jsx(ContextMenu.Positioner, {
89
- collisionPadding: padding,
89
+ ...collision,
90
90
  ...props
91
91
  });
92
92
  }
@@ -2,7 +2,7 @@ import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
3
  import { Button } from "../button/button.mjs";
4
4
  import { Badge } from "../badge/badge.mjs";
5
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
5
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
6
6
  import { Kbd } from "../kbd/kbd.mjs";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import { Menu } from "@base-ui/react/menu";
@@ -99,10 +99,10 @@ function DropdownMenuPopup({ className, ...props }) {
99
99
  ...props
100
100
  });
101
101
  }
102
- function DropdownMenuFramePositioner({ collisionPadding, container, ...props }) {
103
- const padding = usePopupCollisionPadding(collisionPadding, 5, container);
102
+ function DropdownMenuFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
103
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 5, container);
104
104
  return /* @__PURE__ */ jsx(Menu.Positioner, {
105
- collisionPadding: padding,
105
+ ...collision,
106
106
  ...props
107
107
  });
108
108
  }
@@ -5,8 +5,8 @@ const messageFooter = tv({
5
5
  savedIndicator: [
6
6
  "inline-flex min-w-0 max-w-full items-center gap-1 overflow-hidden whitespace-nowrap select-none",
7
7
  "[&_svg]:shrink-0",
8
- "h-[22px] rounded-chip pr-[6.5px] pl-[5.5px]",
9
- "text-caption font-semibold text-foreground-muted",
8
+ "h-6 rounded-chip pr-[6.5px] pl-[5.5px]",
9
+ "text-footnote font-bold text-foreground-muted",
10
10
  "bg-fill-muted hover:overlay-medium active:overlay-medium",
11
11
  "outline-none focus-visible:shadow-focus",
12
12
  "[&_svg]:size-3.5 [&_svg]:text-foreground"
@@ -2,7 +2,7 @@ import { headEdgePull } from "../../lib/head-edges.mjs";
2
2
  import { tv } from "../../lib/tv.mjs";
3
3
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
4
4
  import { Button } from "../button/button.mjs";
5
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
5
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
6
6
  import { Status } from "../status/status.mjs";
7
7
  import { EmptyState } from "../empty-state/empty-state.mjs";
8
8
  import { AlertTriangle, Bell, CheckCircle2, Info } from "lucide-react";
@@ -132,10 +132,10 @@ function NotificationCenterTrigger({ className, children, ...props }) {
132
132
  function NotificationCenterPortal(props) {
133
133
  return /* @__PURE__ */ jsx(Popover.Portal, { ...useThemeScopeProps(props) });
134
134
  }
135
- function NotificationCenterFramePositioner({ collisionPadding, container, defaultCollisionPadding, ...props }) {
136
- const padding = usePopupCollisionPadding(collisionPadding, defaultCollisionPadding, container);
135
+ function NotificationCenterFramePositioner({ collisionBoundary, collisionPadding, container, defaultCollisionPadding, ...props }) {
136
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, defaultCollisionPadding, container);
137
137
  return /* @__PURE__ */ jsx(Popover.Positioner, {
138
- collisionPadding: padding,
138
+ ...collision,
139
139
  ...props
140
140
  });
141
141
  }
@@ -1,6 +1,6 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
3
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import { Popover } from "@base-ui/react/popover";
6
6
  //#region src/components/popover/popover.tsx
@@ -60,10 +60,10 @@ function PopoverPopup({ className, ...props }) {
60
60
  ...props
61
61
  });
62
62
  }
63
- function PopoverFramePositioner({ collisionPadding, container, ...props }) {
64
- const padding = usePopupCollisionPadding(collisionPadding, 5, container);
63
+ function PopoverFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
64
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 5, container);
65
65
  return /* @__PURE__ */ jsx(Popover.Positioner, {
66
- collisionPadding: padding,
66
+ ...collision,
67
67
  ...props
68
68
  });
69
69
  }
@@ -1,7 +1,7 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useTheme } from "../../lib/theme/use-theme.mjs";
3
3
  import { resolveThemeScopeProps, useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
4
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
4
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
  import { PreviewCard } from "@base-ui/react/preview-card";
7
7
  //#region src/components/preview-card/preview-card.tsx
@@ -72,10 +72,10 @@ function PreviewCardPopup({ className, ...props }) {
72
72
  ...scopeProps
73
73
  });
74
74
  }
75
- function PreviewCardFramePositioner({ collisionPadding, container, ...props }) {
76
- const padding = usePopupCollisionPadding(collisionPadding, 5, container);
75
+ function PreviewCardFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
76
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 5, container);
77
77
  return /* @__PURE__ */ jsx(PreviewCard.Positioner, {
78
- collisionPadding: padding,
78
+ ...collision,
79
79
  ...props
80
80
  });
81
81
  }
@@ -1,7 +1,7 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
3
  import { Button } from "../button/button.mjs";
4
- import { usePopupCollisionPadding } from "../../lib/frame-insets.mjs";
4
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
5
5
  import { useOverflowTitle } from "../../lib/use-overflow-title.mjs";
6
6
  import { Check, ChevronDown } from "lucide-react";
7
7
  import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
@@ -264,10 +264,10 @@ function SelectPopup({ className, ...props }) {
264
264
  ...props
265
265
  });
266
266
  }
267
- function SelectFramePositioner({ collisionPadding, container, ...props }) {
268
- const padding = usePopupCollisionPadding(collisionPadding, 8, container);
267
+ function SelectFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
268
+ const collision = usePopupCollision(collisionPadding, collisionBoundary, 8, container);
269
269
  return /* @__PURE__ */ jsx(Select.Positioner, {
270
- collisionPadding: padding,
270
+ ...collision,
271
271
  ...props
272
272
  });
273
273
  }
@@ -11,7 +11,8 @@ const sidebarSectionRecipe = tv({ slots: {
11
11
  sidebarSectionHeader: [
12
12
  "flex items-center justify-between",
13
13
  "group/section-header",
14
- "my-0 h-auto gap-2 px-5 pt-4 pb-0",
14
+ "my-0 h-auto gap-2 pt-4 pb-0 pl-5",
15
+ "pr-[calc(max(0px,calc(8px-var(--scrollbar-size,0px)))+12px)]",
15
16
  "not-pointer-fine:pb-1.5"
16
17
  ],
17
18
  sidebarSectionDisclosure: [
@@ -38,11 +39,16 @@ const sidebarSectionRecipe = tv({ slots: {
38
39
  "not-pointer-fine:static not-pointer-fine:pointer-events-auto not-pointer-fine:translate-y-0 not-pointer-fine:opacity-100",
39
40
  "pointer-fine:group-hover/section-header:pointer-events-auto pointer-fine:group-hover/section-header:opacity-100",
40
41
  "pointer-fine:focus-within:pointer-events-auto pointer-fine:focus-within:opacity-100",
41
- "pointer-fine:has-data-popup-open:pointer-events-auto pointer-fine:has-data-popup-open:opacity-100"
42
+ "pointer-fine:has-data-popup-open:pointer-events-auto pointer-fine:has-data-popup-open:opacity-100",
43
+ "[&>[data-icon-inset]:last-child]:-mr-icon-inset"
42
44
  ],
43
45
  sidebarSectionControl: [
44
46
  "relative flex h-6 w-auto min-w-6 shrink-0 items-center justify-center p-0 -my-1",
45
47
  "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
48
+ "[--icon-inset:calc((--spacing(6)-14px)/2)]",
49
+ "text-foreground-hint",
50
+ "hover:text-foreground active:text-foreground focus-visible:text-foreground",
51
+ "data-popup-open:bg-fill-medium data-popup-open:text-foreground",
46
52
  "not-pointer-fine:after:absolute not-pointer-fine:after:inset-[-11px] not-pointer-fine:after:content-['']"
47
53
  ]
48
54
  } });
@@ -1,7 +1,7 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
3
- import { frameCollisionPadding, useFrameInsets } from "../../lib/frame-insets.mjs";
4
- import { Children, Fragment, isValidElement, useMemo } from "react";
3
+ import { usePopupCollision } from "../../lib/frame-insets.mjs";
4
+ import { Children, Fragment, isValidElement } from "react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { Tooltip } from "@base-ui/react/tooltip";
7
7
  //#region src/components/tooltip/tooltip.tsx
@@ -153,13 +153,17 @@ const CONTENT_COLLISION_AVOIDANCE = {
153
153
  * Inside the portal, so it mounts each time the tooltip opens and reads the
154
154
  * frame insets then (lib/frame-insets.ts): a tooltip that would open into
155
155
  * one flips away from it, and one that would overlap it along its trigger
156
- * shifts out of it. Its padding grows to the inset itself, not past it.
156
+ * shifts out of it. It keeps to the inset itself, not past it.
157
+ *
158
+ * The insets are its collision boundary, not its padding (frameCollision):
159
+ * Base UI sizes the shift limiter from the padding, so a padding carrying a
160
+ * window-controls inset pushed every short tooltip off its trigger, its arrow
161
+ * off center, by up to half the left and right insets.
157
162
  */
158
- function TooltipFramePositioner({ collisionPadding, container, ...props }) {
159
- const insets = useFrameInsets(container);
160
- const padding = useMemo(() => frameCollisionPadding(collisionPadding ?? 5, insets, 0), [collisionPadding, insets]);
163
+ function TooltipFramePositioner({ collisionBoundary, collisionPadding, container, ...props }) {
164
+ const collision = usePopupCollision(void 0, collisionBoundary, collisionPadding ?? 5, container, 0);
161
165
  return /* @__PURE__ */ jsx(Tooltip.Positioner, {
162
- collisionPadding: padding,
166
+ ...collision,
163
167
  ...props
164
168
  });
165
169
  }
@@ -110,18 +110,102 @@ function useFrameInsets(container) {
110
110
  const [insets] = useState(() => readFrameInsets(container));
111
111
  return insets;
112
112
  }
113
+ function hasInset(insets) {
114
+ return insets.top !== 0 || insets.right !== 0 || insets.bottom !== 0 || insets.left !== 0;
115
+ }
113
116
  /**
114
- * A popup's collision padding (a popover, a menu, a select or combobox list,
115
- * a preview card): `defaultPadding` where there is no inset, and 8px inside
116
- * each inset. A `collisionPadding` the caller passes wins as it is.
117
+ * Whether a popup's portal container is the document's own: none, the body or the root.
118
+ * A ref counts as a container of its own whatever it holds now: the caller memoizes on
119
+ * the ref object, which never changes, so reading `.current` here would take an empty
120
+ * ref (a first render, a keepMounted popup) for the body for good. Erring this way only
121
+ * keeps the padding, never clips the popup.
117
122
  */
118
- function usePopupCollisionPadding(collisionPadding, defaultPadding, container) {
123
+ function portalsToBody(container) {
124
+ if (container && "current" in container) return false;
125
+ const node = container;
126
+ const element = node && !(node instanceof Element) && "host" in node ? node.host : node;
127
+ return element == null || element === document.body || element === document.documentElement;
128
+ }
129
+ /**
130
+ * The window inset by the frame insets, as a collision boundary rect. Its size
131
+ * is read each time Base UI places the popup (it copies the rect's fields), so
132
+ * it follows the window when it resizes while the popup is open.
133
+ */
134
+ function frameCollisionBoundary(insets) {
135
+ const root = () => document.documentElement;
136
+ return {
137
+ x: insets.left,
138
+ y: insets.top,
139
+ get width() {
140
+ return Math.max(0, root().clientWidth - insets.left - insets.right);
141
+ },
142
+ get height() {
143
+ return Math.max(0, root().clientHeight - insets.top - insets.bottom);
144
+ }
145
+ };
146
+ }
147
+ /**
148
+ * How a popup keeps out of the frame insets: `padding` from the window's edge,
149
+ * and on each side with an inset, at least the inset plus `gap`
150
+ * (frameCollisionPadding).
151
+ *
152
+ * The insets bound the collision area (frameCollisionBoundary) and the padding
153
+ * keeps only what is left past them. They are not added to the padding,
154
+ * because Base UI also sizes its shift limiter from the padding (limitShift
155
+ * offset = arrow / 2 + (left + right) / 2, or top + bottom beside the trigger):
156
+ * a padding that carried a 70px window-controls inset pushed a short tooltip
157
+ * with an arrow up to that half-sum off its trigger with nothing in the way.
158
+ * The area the popup is kept in is the same either way.
159
+ *
160
+ * Only a popup portaled to the body (no `container`, or the body or the root
161
+ * element) takes the boundary. One portaled into a container of its own may
162
+ * sit inside an element that clips it (a dialog's scrolling body), and an
163
+ * explicit boundary would replace Base UI's default, the clipping ancestors,
164
+ * so it would flip and size against the window and be cut off.
165
+ *
166
+ * With no inset, a `boundary` of the caller's own (it bounds the popup as it
167
+ * is, so the insets go into the padding), or a container of its own, this is
168
+ * frameCollisionPadding.
169
+ */
170
+ function frameCollision(padding, boundary, insets, gap, container) {
171
+ const padded = frameCollisionPadding(padding, insets, gap);
172
+ if (!hasInset(insets) || boundary !== void 0 || typeof document === "undefined" || !portalsToBody(container)) return {
173
+ collisionPadding: padded,
174
+ collisionBoundary: boundary
175
+ };
176
+ const full = padded;
177
+ const side = (key) => Math.max(0, full[key] - insets[key]);
178
+ return {
179
+ collisionPadding: {
180
+ top: side("top"),
181
+ right: side("right"),
182
+ bottom: side("bottom"),
183
+ left: side("left")
184
+ },
185
+ collisionBoundary: frameCollisionBoundary(insets)
186
+ };
187
+ }
188
+ /**
189
+ * A popup's collision padding and boundary (a tooltip, a popover, a menu, a
190
+ * select or combobox list, a preview card): `defaultPadding` where there is no
191
+ * inset, and `gap` (8px) inside each inset, the insets as the boundary
192
+ * (frameCollision). A `collisionPadding` the caller passes wins as it is, and
193
+ * a `collisionBoundary` the caller passes is kept. Call it from a part inside
194
+ * the popup's portal, which mounts each time the popup opens.
195
+ */
196
+ function usePopupCollision(collisionPadding, collisionBoundary, defaultPadding, container, gap = 8) {
119
197
  const insets = useFrameInsets(container);
120
- return useMemo(() => collisionPadding ?? frameCollisionPadding(defaultPadding, insets, 8), [
198
+ return useMemo(() => collisionPadding === void 0 ? frameCollision(defaultPadding, collisionBoundary, insets, gap, container) : {
199
+ collisionPadding,
200
+ collisionBoundary
201
+ }, [
121
202
  collisionPadding,
203
+ collisionBoundary,
122
204
  defaultPadding,
123
- insets
205
+ insets,
206
+ gap,
207
+ container
124
208
  ]);
125
209
  }
126
210
  //#endregion
127
- export { NO_FRAME_INSETS, frameCollisionPadding, frameInsetsFrom, readFrameInsets, useFrameInsets, usePopupCollisionPadding };
211
+ export { NO_FRAME_INSETS, frameCollision, frameCollisionBoundary, frameCollisionPadding, frameInsetsFrom, readFrameInsets, useFrameInsets, usePopupCollision };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",