@botiverse/raft-ui 0.74.0 → 0.76.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,35 @@
1
+ ## [0.76.0](https://github.com/botiverse/raft-design/compare/v0.75.0...v0.76.0) (2026-11-08)
2
+
3
+ ### Changed
4
+
5
+ - **VirtualList writes no `--virtual-list-width`**: the list no longer writes its width as a CSS variable on its scroller, and `refreshListWidth()` on its imperative API does nothing (deprecated; it will be removed). ([#1068](https://github.com/botiverse/raft-design/pull/1068))
6
+ - Why: the variable inherits, so a write on the scroller restyled every row under it, on every frame of a window resize, and nothing read it (InNOut's trace of a window drag in the app, #proj-frontend:57f32c96).
7
+ - Affects: CSS that read `var(--virtual-list-width)` gets nothing; read the width with the list's `onResize` or a ResizeObserver. Neither raft-ui nor the Raft app read it.
8
+ - Docs: https://raft-design.botiverse.build/components/virtual-list/
9
+
10
+ ### Fixed
11
+
12
+ - **Resizing a window no longer restyles everything inside a ScrollArea**: the sizes ScrollArea measures are no longer written on the root. With sticky gutters, the viewport's height (`--r-scroll-area-viewport-height`) and the container's padding (`--r-scroll-area-padding-top`, `-left` and `-right`) go on the gutters layer, and with `fade` the viewport's height and width (`--r-scroll-area-viewport-width`) on the content its mask is on; with absolute gutters, which read none of them, nothing is written. They are registered not to inherit (`@property`). A message timeline (`useScrollAreaContainer` on a VirtualList) restyled every message on every frame of a window drag. ([#1068](https://github.com/botiverse/raft-design/pull/1068))
13
+ - Why: in the app, dragging the window's edge took 61-73ms a frame, 75-85% of it restyling, because the viewport's size changed on the root of each column's message list (InNOut, #proj-frontend:57f32c96).
14
+ - Affects: dragging the window's edge in the Raft app (a channel with a thread open, 1600 to 900 to 1600px wide): style recalculation 2.7s to 1.5s and the median step 52 to 27ms with this release, 0.3s and 17ms together with the app's own fix. CSS that read those variables inside an area now sees their initial values (100% and 0px); they were internal, and neither raft-ui nor the Raft app read them. How the scrollbars and `fade` look is unchanged.
15
+ - Docs: https://raft-design.botiverse.build/components/scroll-area/
16
+
17
+ ## [0.75.0](https://github.com/botiverse/raft-design/compare/v0.74.0...v0.75.0) (2026-11-08)
18
+
19
+ ### Changed
20
+
21
+ - **A horizontal DescriptionList puts its labels above their values only when a value doesn't fit**: the list measures its labels and values and keeps each label beside its value while every value fits on one line there; once one doesn't (but would on a line of its own), or the value column would be narrower than 160px, the whole list stacks. It no longer stacks below a fixed 24rem (384px), and is no longer a size container. MessageActionCard's properties follow the same rule. ([#1065](https://github.com/botiverse/raft-design/pull/1065))
22
+ - Why: an agent's Role, Computer and Model stacked in a 370px profile though they fit beside their labels; the switch should follow the content, not a width picked for the longest label (Artea, #triage:428e3ce6).
23
+ - Affects: short facts in panels from about 300px to 384px, and on a phone, now stand beside their labels. A list whose values are wide still stacks, at the width they stop fitting.
24
+ - Docs: https://raft-design.botiverse.build/components/description-list/#labels-go-above-only-when-a-value-doesnt-fit
25
+
26
+ ### Fixed
27
+
28
+ - **Chinese text on Linux uses Noto Sans CJK**: the Simplified Chinese fallback lists `Noto Sans CJK SC`, the name Linux distributions install it under, after `Noto Sans SC`, as the Traditional Chinese list already did with `Noto Sans CJK TC`. ([#1063](https://github.com/botiverse/raft-design/pull/1063))
29
+ - Why: on a Linux machine with only the distribution's CJK font, Chinese and its full-width punctuation fell to the system's last-resort face, whose colon sits centered with space around it (Remy, #proj-raft-ui:0c3f4e06; seen in the read line's screenshots from CI).
30
+ - Affects: Linux desktops and CI screenshots; Mac and Windows already matched PingFang SC or Microsoft YaHei first.
31
+ - Docs: https://raft-design.botiverse.build/foundations/typography/
32
+
1
33
  ## [0.74.0](https://github.com/botiverse/raft-design/compare/v0.73.0...v0.74.0) (2026-11-08)
2
34
 
3
35
  ### Added
@@ -49,7 +49,7 @@ declare const descriptionList: import("tailwind-variants/lite").TVReturnType<{
49
49
  }>>;
50
50
  export type DescriptionListDirection = NonNullable<VariantProps<typeof descriptionList>["direction"]>;
51
51
  export type DescriptionListProps = useRender.ComponentProps<"dl"> & Pick<VariantProps<typeof descriptionList>, "direction">;
52
- export declare function DescriptionList({ direction, className, render, ...props }: DescriptionListProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
52
+ export declare function DescriptionList({ direction, className, render, ref, ...props }: DescriptionListProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
53
53
  export type DescriptionItemProps = useRender.ComponentProps<"div">;
54
54
  export declare function DescriptionItem({ className, render, ...props }: DescriptionItemProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
55
55
  export type DescriptionTermProps = useRender.ComponentProps<"dt">;
@@ -1,4 +1,6 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
+ import { useLabelValueFit } from "../../lib/label-value-fit.mjs";
3
+ import { useCallback, useState } from "react";
2
4
  import { mergeProps } from "@base-ui/react/merge-props";
3
5
  import { useRender } from "@base-ui/react/use-render";
4
6
  //#region src/components/description-list/description-list.tsx
@@ -21,10 +23,10 @@ const descriptionList = tv({
21
23
  variants: { direction: {
22
24
  vertical: {},
23
25
  horizontal: { root: [
24
- "@container/description-list w-full grid items-baseline",
25
- "[&>[data-slot=description-item]>*]:@max-sm/description-list:col-span-full",
26
- "[&>[data-slot=description-item]>[data-slot=description-details]]:@max-sm/description-list:min-h-0",
27
- "[&>[data-slot=description-item]+[data-slot=description-item]>[data-slot=description-term]]:@max-sm/description-list:mt-2",
26
+ "w-full grid items-baseline",
27
+ "[&[data-stacked]>[data-slot=description-item]>*]:col-span-full",
28
+ "[&[data-stacked]>[data-slot=description-item]>[data-slot=description-details]]:min-h-0",
29
+ "[&[data-stacked]>[data-slot=description-item]+[data-slot=description-item]>[data-slot=description-term]]:mt-2",
28
30
  "*:data-[slot=description-item]:contents",
29
31
  "[&>[data-slot=description-item]>[data-slot=description-term]]:mb-0",
30
32
  "[&>[data-slot=description-item]>[data-slot=description-term]]:max-w-40",
@@ -52,12 +54,20 @@ const descriptionList = tv({
52
54
  } },
53
55
  defaultVariants: { direction: "vertical" }
54
56
  });
55
- function DescriptionList({ direction = "vertical", className, render, ...props }) {
57
+ const listParts = {
58
+ label: ":scope > [data-slot=description-item] > [data-slot=description-term]",
59
+ value: ":scope > [data-slot=description-item] > [data-slot=description-details]"
60
+ };
61
+ function DescriptionList({ direction = "vertical", className, render, ref, ...props }) {
62
+ const [list, setList] = useState(null);
63
+ const listRef = useCallback((element) => setList(element), []);
64
+ useLabelValueFit(list, direction === "horizontal", listParts);
56
65
  const { root } = descriptionList({ direction });
57
66
  const defaultProps = { className: root({ className }) };
58
67
  return useRender({
59
68
  defaultTagName: "dl",
60
69
  render,
70
+ ref: ref ? [listRef, ref] : listRef,
61
71
  props: mergeProps(defaultProps, props),
62
72
  state: { slot: "description-list" }
63
73
  });
@@ -1,7 +1,7 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
2
  //#region src/components/message-action-card/message-action-card-content.recipe.ts
3
3
  const messageActionCardContent = tv({ slots: {
4
- root: ["p-3", "@container/action-card flex flex-col gap-2 px-3 pt-0 pb-3"],
4
+ root: ["p-3", "flex flex-col gap-2 px-3 pt-0 pb-3"],
5
5
  panel: [
6
6
  "h-(--collapsible-panel-height) overflow-hidden",
7
7
  "transition-[height] duration-200 ease-out motion-reduce:transition-none",
@@ -9,19 +9,19 @@ const messageActionCardContent = tv({ slots: {
9
9
  ],
10
10
  properties: [
11
11
  "m-0 grid",
12
- "@max-sm/action-card:grid-cols-1",
12
+ "data-stacked:grid-cols-1",
13
13
  "grid-cols-[minmax(88px,max-content)_minmax(0,1fr)] items-baseline gap-x-4 gap-y-0.5"
14
14
  ],
15
15
  property: ["contents"],
16
16
  propertyLabel: [
17
17
  "max-w-40 min-w-0 wrap-break-word",
18
18
  "text-footnote font-medium text-foreground-hint",
19
- "[[data-slot=message-action-card-property]+[data-slot=message-action-card-property]>&]:@max-sm/action-card:mt-2"
19
+ "[[data-stacked]>[data-slot=message-action-card-property]+[data-slot=message-action-card-property]>&]:mt-2"
20
20
  ],
21
21
  propertyValue: [
22
22
  "m-0 min-w-0 wrap-break-word",
23
23
  "min-h-8 py-1.5 text-callout text-foreground",
24
- "@max-sm/action-card:min-h-0 @max-sm/action-card:py-0"
24
+ "[[data-stacked]>[data-slot=message-action-card-property]>&]:min-h-0 [[data-stacked]>[data-slot=message-action-card-property]>&]:py-0"
25
25
  ],
26
26
  scopeList: ["mt-0 stack-y-1.5"],
27
27
  scopeListViewport: ["max-h-[136px]"],
@@ -32,7 +32,7 @@ export declare function MessageActionCardStatus({ className, appearance, ...prop
32
32
  export declare function MessageActionCardCollapseTrigger({ className, ...props }: MessageActionCardCollapseTriggerProps): import("react").JSX.Element;
33
33
  export declare function MessageActionCardHeader({ className, render, ...props }: useRender.ComponentProps<"div">): import("react").JSX.Element;
34
34
  export declare function MessageActionCardHeaderStatus({ className, render, ...props }: useRender.ComponentProps<"span">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
35
- export declare function MessageActionCardProperties({ className, render, ...props }: useRender.ComponentProps<"dl">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
35
+ export declare function MessageActionCardProperties({ className, render, ref, ...props }: useRender.ComponentProps<"dl">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
36
36
  export declare function MessageActionCardProperty({ className, render, ...props }: useRender.ComponentProps<"div">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
37
37
  export declare function MessageActionCardPropertyLabel({ className, render, ...props }: useRender.ComponentProps<"dt">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
38
38
  export declare function MessageActionCardPropertyValue({ className, render, ...props }: useRender.ComponentProps<"dd">): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,10 +1,12 @@
1
1
  import { ScrollArea } from "../scroll-area/scroll-area.mjs";
2
2
  import { Badge } from "../badge/badge.mjs";
3
+ import { useLabelValueFit } from "../../lib/label-value-fit.mjs";
3
4
  import { MessageEmbed, MessageEmbedContent, MessageEmbedHeader } from "../message-embed/message-embed.mjs";
4
5
  import { messageActionCard } from "./message-action-card.recipe.mjs";
5
6
  import { messageActionCardContent } from "./message-action-card-content.recipe.mjs";
6
7
  import { ChevronDown, Info, Zap } from "lucide-react";
7
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ import { useCallback, useState } from "react";
9
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
10
  import { mergeProps } from "@base-ui/react/merge-props";
9
11
  import { useRender } from "@base-ui/react/use-render";
10
12
  import { Collapsible } from "@base-ui/react/collapsible";
@@ -127,11 +129,19 @@ function MessageActionCardHeaderStatus({ className, render, ...props }) {
127
129
  state: { slot: "message-action-card-header-status" }
128
130
  });
129
131
  }
130
- function MessageActionCardProperties({ className, render, ...props }) {
132
+ const propertyParts = {
133
+ label: ":scope > [data-slot=message-action-card-property] > [data-slot=message-action-card-property-label]",
134
+ value: ":scope > [data-slot=message-action-card-property] > [data-slot=message-action-card-property-value]"
135
+ };
136
+ function MessageActionCardProperties({ className, render, ref, ...props }) {
137
+ const [list, setList] = useState(null);
138
+ const listRef = useCallback((element) => setList(element), []);
139
+ useLabelValueFit(list, true, propertyParts);
131
140
  const { properties } = messageActionCardContent();
132
141
  return useRender({
133
142
  defaultTagName: "dl",
134
143
  render,
144
+ ref: ref ? [listRef, ref] : listRef,
135
145
  props: mergeProps({ className: properties({ className }) }, props),
136
146
  state: { slot: "message-action-card-properties" }
137
147
  });
@@ -249,7 +259,7 @@ function MessageActionCardNotice({ className, render, icon, children, ...props }
249
259
  render,
250
260
  props: mergeProps({
251
261
  className: notice({ className }),
252
- children: /* @__PURE__ */ jsxs(Fragment, { children: [icon ?? /* @__PURE__ */ jsx(Info, { "aria-hidden": true }), /* @__PURE__ */ jsx("span", { children })] })
262
+ children: /* @__PURE__ */ jsxs(Fragment$1, { children: [icon ?? /* @__PURE__ */ jsx(Info, { "aria-hidden": true }), /* @__PURE__ */ jsx("span", { children })] })
253
263
  }, props),
254
264
  state: { slot: "message-action-card-notice" }
255
265
  });
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * sticky the root is the container; the gutters are its first child, a
9
9
  * zero-height layer stuck to the top of the viewport and sized
10
- * by --r-scroll-area-viewport-{height,width}.
10
+ * by --r-scroll-area-viewport-height.
11
11
  * absolute the root is a positioned frame holding the container and,
12
12
  * after it, the gutters, laid over the frame (`inset: 0`). For a
13
13
  * container that can't hold children: a textarea.
@@ -49,10 +49,15 @@
49
49
  * --r-scroll-area-timeline-{y,x} the names of the container's scroll
50
50
  * timelines, unique to the ScrollArea
51
51
  * What the script writes as inline styles (React never renders them):
52
- * --r-scroll-area-viewport-{height,width} on the root: the container's
52
+ * --r-scroll-area-viewport-height with sticky gutters, on the
53
+ * gutters layer: the container's
54
+ * client height (layout path only)
55
+ * --r-scroll-area-viewport-{height,width} with sticky gutters and `fade`,
56
+ * on the content: the container's
53
57
  * client size (layout path only)
54
- * --r-scroll-area-padding-{top,left,right} on a sticky root: its padding,
55
- * which the gutters layer
58
+ * --r-scroll-area-padding-{top,left,right} with sticky gutters, on the
59
+ * gutters layer: the container's
60
+ * padding, which the layer
56
61
  * reaches over (layout path only)
57
62
  * --r-scroll-area-thumb-length on each thumb: its length, px
58
63
  * --r-scroll-area-thumb-travel on each thumb, with scroll-driven
@@ -67,8 +72,9 @@
67
72
  * (sticky) and see it, or the root hoists it (`timeline-scope`, absolute).
68
73
  * The script then writes only when a size changes. Elsewhere a scrolled
69
74
  * frame changes the thumbs' transforms, and the length with them when the
70
- * content's size changed. Never the root while scrolling, whose variables
71
- * everything inside would inherit.
75
+ * content's size changed. Never the root, whose variables everything inside
76
+ * would inherit: a write there, on every frame of a window resize, restyled
77
+ * every row of a long list under it.
72
78
  *
73
79
  * The thumb is the label colour (`foreground`) made translucent, dark on
74
80
  * light pages and light on dark ones, so it sits on any content; there is no
@@ -103,6 +109,41 @@
103
109
  initial-value: 0px;
104
110
  }
105
111
 
112
+ /*
113
+ * The container's client size and padding, written by the script on each
114
+ * layout (every frame of a window resize) on the elements that read them,
115
+ * with sticky gutters only: the height and the padding on the gutters layer,
116
+ * whose gutters take the height with `inherit`, and with `fade` the size on
117
+ * the content, whose mask is one viewport large. They don't inherit:
118
+ * a change restyles those few elements, not the content under them. 100%
119
+ * until the first measurement, as the gutters and the mask were before it.
120
+ */
121
+ @property --r-scroll-area-viewport-height {
122
+ syntax: "<length-percentage>";
123
+ inherits: false;
124
+ initial-value: 100%;
125
+ }
126
+ @property --r-scroll-area-viewport-width {
127
+ syntax: "<length-percentage>";
128
+ inherits: false;
129
+ initial-value: 100%;
130
+ }
131
+ @property --r-scroll-area-padding-top {
132
+ syntax: "<length>";
133
+ inherits: false;
134
+ initial-value: 0px;
135
+ }
136
+ @property --r-scroll-area-padding-left {
137
+ syntax: "<length>";
138
+ inherits: false;
139
+ initial-value: 0px;
140
+ }
141
+ @property --r-scroll-area-padding-right {
142
+ syntax: "<length>";
143
+ inherits: false;
144
+ initial-value: 0px;
145
+ }
146
+
106
147
  /*
107
148
  * Room kept clear at each end of the scrollbars, such as for a sticky header
108
149
  * or footer inside the area (`railInset`). Set on the root itself; they don't
@@ -170,15 +211,6 @@
170
211
 
171
212
  --r-scroll-area-thumb-size: var(--r-scroll-area-thumb-size-medium);
172
213
  --r-scroll-area-thumb-size-hover: var(--r-scroll-area-thumb-size-hover-medium);
173
- /*
174
- * Until the first measurement, and again for a nested scroll area, which
175
- * would otherwise inherit its parent's.
176
- */
177
- --r-scroll-area-viewport-height: 100%;
178
- --r-scroll-area-viewport-width: 100%;
179
- --r-scroll-area-padding-top: 0px;
180
- --r-scroll-area-padding-left: 0px;
181
- --r-scroll-area-padding-right: 0px;
182
214
  /* The gutter is as thick as the hovered thumb plus the gap on both sides. */
183
215
  --r-scroll-area-rail-size: calc(
184
216
  var(--r-scroll-area-thumb-size-hover) + 2 * var(--r-scroll-area-rail-gap)
@@ -492,6 +524,10 @@
492
524
  position: absolute;
493
525
  transition: opacity var(--r-scroll-area-duration-fade-out) var(--r-scroll-area-easing);
494
526
  }
527
+ /* The viewport's height is written on the layer and doesn't inherit: each sticky gutter takes it. */
528
+ .r-scroll-area.r-scroll-area--sticky-gutters > .r-scroll-area__gutters > .r-scroll-area__gutter {
529
+ --r-scroll-area-viewport-height: inherit;
530
+ }
495
531
  .r-scroll-area > .r-scroll-area__gutters > .r-scroll-area__gutter--vertical {
496
532
  inline-size: var(--r-scroll-area-rail-size);
497
533
  inset-block-start: var(--r-scroll-area-rail-top);
@@ -319,15 +319,19 @@ function useScrollbars({ containerRef, contentRef, fadeRef, guttersPosition, roo
319
319
  if (!container || !root) return;
320
320
  const current = state.current;
321
321
  const axes = latest.current.orientation;
322
+ const sticky = latest.current.guttersPosition === "sticky";
323
+ const gutters = guttersRef.current;
322
324
  if (layout) {
323
325
  const position = latest.current.guttersPosition;
324
- current.timeline = timelineDrives(container, root, guttersRef.current, position);
326
+ current.timeline = timelineDrives(container, root, gutters, position);
325
327
  const style = getComputedStyle(container);
326
328
  current.rtl = axes !== "vertical" && style.direction === "rtl";
327
- if (position === "sticky") {
328
- writeVar(root, "--r-scroll-area-padding-top", style.paddingTop || "0px");
329
- writeVar(root, "--r-scroll-area-padding-left", style.paddingLeft || "0px");
330
- writeVar(root, "--r-scroll-area-padding-right", style.paddingRight || "0px");
329
+ if (sticky) {
330
+ if (gutters) {
331
+ writeVar(gutters, "--r-scroll-area-padding-top", style.paddingTop || "0px");
332
+ writeVar(gutters, "--r-scroll-area-padding-left", style.paddingLeft || "0px");
333
+ writeVar(gutters, "--r-scroll-area-padding-right", style.paddingRight || "0px");
334
+ }
331
335
  warnStickyLayout(container, style);
332
336
  }
333
337
  const min = Number.parseFloat(getComputedStyle(root).getPropertyValue("--r-scroll-area-thumb-min-length"));
@@ -338,8 +342,15 @@ function useScrollbars({ containerRef, contentRef, fadeRef, guttersPosition, roo
338
342
  cache.clientWidth = container.clientWidth;
339
343
  cache.scrollHeight = container.scrollHeight;
340
344
  cache.scrollWidth = container.scrollWidth;
341
- writeVar(root, "--r-scroll-area-viewport-height", px(cache.clientHeight));
342
- writeVar(root, "--r-scroll-area-viewport-width", px(cache.clientWidth));
345
+ if (sticky) {
346
+ const height = px(cache.clientHeight);
347
+ if (gutters) writeVar(gutters, "--r-scroll-area-viewport-height", height);
348
+ const masked = latest.current.fade ? fadeRef?.current ?? null : null;
349
+ if (masked) {
350
+ writeVar(masked, "--r-scroll-area-viewport-height", height);
351
+ writeVar(masked, "--r-scroll-area-viewport-width", px(cache.clientWidth));
352
+ }
353
+ }
343
354
  const sizes = {
344
355
  ...cache,
345
356
  scrollLeft: 0,
@@ -363,6 +374,7 @@ function useScrollbars({ containerRef, contentRef, fadeRef, guttersPosition, roo
363
374
  applyClasses,
364
375
  containerRef,
365
376
  draw,
377
+ fadeRef,
366
378
  guttersRef,
367
379
  rootRef
368
380
  ]);
@@ -452,7 +464,8 @@ function useScrollbars({ containerRef, contentRef, fadeRef, guttersPosition, roo
452
464
  bind,
453
465
  orientation,
454
466
  fade,
455
- guttersPosition
467
+ guttersPosition,
468
+ guttersRef.current
456
469
  ];
457
470
  const current = binding.current;
458
471
  if (current && current.container === container && current.content === content && current.root === root && current.key.every((value, index) => value === key[index])) return;
@@ -913,10 +913,7 @@ function VirtualList(props) {
913
913
  const index = keyToIndexRef.current.get(key);
914
914
  if (index !== void 0) return fenwickTree.sum(index) + getRowsTop();
915
915
  });
916
- const refreshListWidth = useCallback(() => {
917
- const listEl = listElRef.current;
918
- if (listEl) listEl.style.setProperty("--virtual-list-width", `${listEl.offsetWidth}px`);
919
- }, [listElRef]);
916
+ const refreshListWidth = useCallback(() => {}, []);
920
917
  const imperativeApi = useMemo(() => ({
921
918
  listElRef,
922
919
  itemsElRef,
@@ -1054,15 +1051,15 @@ function VirtualList(props) {
1054
1051
  compensationYRef
1055
1052
  });
1056
1053
  /**
1057
- * The list's own resize: stores its height and width, sets `--virtual-list-width` and updates
1058
- * the range. Skipped while the list is hidden with `display: none`.
1054
+ * The list's own resize: stores its height and width and updates the range. Skipped while the
1055
+ * list is hidden with `display: none`. It writes no CSS variable on the list: one would be
1056
+ * inherited by every row, and restyle them all on every frame of a window resize.
1059
1057
  */
1060
1058
  const handleListResize = useEventCallback((listEl, deferRender) => {
1061
1059
  if (isHiddenByDisplayNone(listEl)) return;
1062
1060
  const height = listEl.offsetHeight;
1063
1061
  const width = listEl.offsetWidth;
1064
1062
  const viewport = viewportRef.current;
1065
- listEl.style.setProperty("--virtual-list-width", `${width}px`);
1066
1063
  if (renderCol == null && renderItemWithCols == null) {
1067
1064
  if (height === viewport.listHeight) return;
1068
1065
  } else if (height === viewport.listHeight && width === viewport.listWidth) return;
@@ -103,7 +103,11 @@ export interface VirtualListApi {
103
103
  * scroll position of your own that must hold while the rows it depends on are measured.
104
104
  */
105
105
  submitScrollAdjustingTask: (task: () => void) => void;
106
- /** Writes the scroller's width to `--virtual-list-width` again. */
106
+ /**
107
+ * Does nothing. It wrote the scroller's width to `--virtual-list-width`, which the list no longer
108
+ * writes: read the width with `onResize` or a ResizeObserver.
109
+ * @deprecated The list no longer writes `--virtual-list-width`; this will be removed.
110
+ */
107
111
  refreshListWidth: () => void;
108
112
  }
109
113
  export type RenderReason = "sessionChanged" | "itemsChanged" | "listScroll" | "batchItemResize" | "listResize";
@@ -12,10 +12,9 @@
12
12
  * In the `components` layer, so a utility in the consumer's `className` (`h-*`, `max-h-*`, `overflow-*`)
13
13
  * overrides these rules.
14
14
  *
15
- * CSS variable the list writes as an inline style, for sticky children to read:
16
- * --virtual-list-width on `.r-virtual-list`: the list's offsetWidth
17
- * The rows' element carries no CSS variable: custom properties inherit, so one written there on every
18
- * scroll would restyle every row. Content that must follow the rows' offset uses `onTransformChange`.
15
+ * The list writes no CSS variable: custom properties inherit, so one written on the scroller or the rows'
16
+ * element on every scroll, or every frame of a window resize, would restyle every row. Content that must
17
+ * follow the rows' offset uses `onTransformChange`.
19
18
  */
20
19
  @layer components {
21
20
  /* VirtualList renders its own scroller (scroll-area.md#native-scrolling): it
package/dist/fonts.css CHANGED
@@ -11,10 +11,10 @@
11
11
  :where(:root) {
12
12
  --heading-font:
13
13
  "Space Grotesk", Arial, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
14
- "Noto Sans SC", system-ui, sans-serif;
14
+ "Noto Sans SC", "Noto Sans CJK SC", system-ui, sans-serif;
15
15
  --sans-font:
16
16
  "Space Grotesk", Arial, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
17
- "Noto Sans SC", system-ui, sans-serif;
17
+ "Noto Sans SC", "Noto Sans CJK SC", system-ui, sans-serif;
18
18
  --mono-font: "Space Mono", ui-monospace, "SF Mono", Menlo, monospace;
19
19
  }
20
20
 
@@ -0,0 +1,117 @@
1
+ import { useLayoutEffect } from "react";
2
+ //#region src/lib/label-value-fit.ts
3
+ /** The narrowest the label column gets: `minmax(88px, max-content)` in both lists. */
4
+ const minLabelColumn = 88;
5
+ /**
6
+ * The narrowest the value column may be beside its labels: as wide as a label
7
+ * may grow (`max-w-40`). A value column narrower than its label column reads
8
+ * as the label's footnote, not its answer.
9
+ */
10
+ const minValueColumn = 160;
11
+ /**
12
+ * Reads every element's width with nothing wrapping inside it: all of them
13
+ * go to one-line layout, are read in one pass and get their inline style
14
+ * back, so the list is laid out once for the read, not once per element.
15
+ */
16
+ function oneLineWidths(els) {
17
+ const saved = els.map(({ style }) => [
18
+ style.width,
19
+ style.maxWidth,
20
+ style.whiteSpace,
21
+ style.flexWrap
22
+ ]);
23
+ for (const { style } of els) {
24
+ style.width = "max-content";
25
+ style.maxWidth = "none";
26
+ style.whiteSpace = "nowrap";
27
+ style.flexWrap = "nowrap";
28
+ }
29
+ const widths = els.map((el) => el.getBoundingClientRect().width);
30
+ els.forEach(({ style }, i) => {
31
+ [style.width, style.maxWidth, style.whiteSpace, style.flexWrap] = saved[i];
32
+ });
33
+ return widths;
34
+ }
35
+ /**
36
+ * Whether a two-column label/value list has to put its labels above their
37
+ * values: some value doesn't fit on one line beside the label column, but
38
+ * would on a line of its own, or the value column would be narrower than
39
+ * `minValueColumn`. A value too long for a line of its own (a paragraph)
40
+ * wraps either way and doesn't decide. Measured in one-line layout whatever
41
+ * the list shows now, so the answer depends only on the list's width and its
42
+ * content and can't flap at the boundary.
43
+ */
44
+ function labelsGoAbove(list, parts) {
45
+ const style = getComputedStyle(list);
46
+ const width = list.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
47
+ const gap = parseFloat(style.columnGap) || 0;
48
+ const labels = Array.from(list.querySelectorAll(parts.label));
49
+ const values = Array.from(list.querySelectorAll(parts.value));
50
+ const caps = labels.map((label) => parseFloat(getComputedStyle(label).maxWidth));
51
+ const widths = oneLineWidths([...labels, ...values]);
52
+ let labelColumn = minLabelColumn;
53
+ labels.forEach((_, i) => {
54
+ const natural = widths[i];
55
+ const cap = caps[i];
56
+ labelColumn = Math.max(labelColumn, Number.isNaN(cap) ? natural : Math.min(natural, cap));
57
+ });
58
+ const valueColumn = width - labelColumn - gap;
59
+ if (valueColumn < minValueColumn) return true;
60
+ for (const natural of widths.slice(labels.length)) if (natural - valueColumn > .5 && natural - width <= .5) return true;
61
+ return false;
62
+ }
63
+ /**
64
+ * Sets `data-stacked` on a two-column label/value list while its labels have
65
+ * to go above their values (`labelsGoAbove`), and takes it off when they fit
66
+ * beside them again: the list's CSS does the rest. The decision is taken
67
+ * synchronously, in the layout effect and the observers' callbacks, so the
68
+ * first frame and every resize paint the right layout; again when the list
69
+ * or a value resizes, a label or value changes, or a web font arrives
70
+ * (design/patterns/responsive.md#when-a-row-runs-out-of-room).
71
+ */
72
+ function useLabelValueFit(list, enabled, parts) {
73
+ const { label, value } = parts;
74
+ useLayoutEffect(() => {
75
+ if (!list || !enabled || typeof ResizeObserver === "undefined") return void 0;
76
+ const selectors = {
77
+ label,
78
+ value
79
+ };
80
+ const fit = () => {
81
+ const stacked = labelsGoAbove(list, selectors);
82
+ if (stacked !== list.hasAttribute("data-stacked")) list.toggleAttribute("data-stacked", stacked);
83
+ };
84
+ const resize = new ResizeObserver(fit);
85
+ const observeAll = () => {
86
+ resize.disconnect();
87
+ resize.observe(list);
88
+ for (const el of Array.from(list.querySelectorAll(`${label}, ${value}`))) for (const child of Array.from(el.children)) resize.observe(child);
89
+ };
90
+ observeAll();
91
+ const mutation = new MutationObserver(() => {
92
+ observeAll();
93
+ fit();
94
+ });
95
+ mutation.observe(list, {
96
+ childList: true,
97
+ subtree: true,
98
+ characterData: true
99
+ });
100
+ const fonts = typeof document === "undefined" ? void 0 : document.fonts;
101
+ fonts?.addEventListener("loadingdone", fit);
102
+ fit();
103
+ return () => {
104
+ resize.disconnect();
105
+ mutation.disconnect();
106
+ fonts?.removeEventListener("loadingdone", fit);
107
+ list.removeAttribute("data-stacked");
108
+ };
109
+ }, [
110
+ list,
111
+ enabled,
112
+ label,
113
+ value
114
+ ]);
115
+ }
116
+ //#endregion
117
+ export { labelsGoAbove, useLabelValueFit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",