@botiverse/raft-ui 0.73.0 → 0.75.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,41 @@
1
+ ## [0.75.0](https://github.com/botiverse/raft-design/compare/v0.74.0...v0.75.0) (2026-11-08)
2
+
3
+ ### Changed
4
+
5
+ - **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))
6
+ - 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).
7
+ - 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.
8
+ - Docs: https://raft-design.botiverse.build/components/description-list/#labels-go-above-only-when-a-value-doesnt-fit
9
+
10
+ ### Fixed
11
+
12
+ - **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))
13
+ - 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).
14
+ - Affects: Linux desktops and CI screenshots; Mac and Windows already matched PingFang SC or Microsoft YaHei first.
15
+ - Docs: https://raft-design.botiverse.build/foundations/typography/
16
+
17
+ ## [0.74.0](https://github.com/botiverse/raft-design/compare/v0.73.0...v0.74.0) (2026-11-08)
18
+
19
+ ### Added
20
+
21
+ - **Lint rules for apps**: `@botiverse/raft-ui/lint` is a lint plugin with the design system's rules for app code, in the ESLint plugin format, which Oxlint loads as a JS plugin. Its first rule, `raft-ui/prefer-scroll-area`, reports a class or an inline style that makes an element scroll natively (`overflow-auto`, `overflow-scroll` and their `-x-` and `-y-` forms, with any variant) where an app writes it, unless a `// raft-ui: native-scroll <reason>` comment on the line above says which exception it is. The comment covers the string on the next line only, not the rest of a multi-line `cn(...)`, so a class added to the list later is still checked. Turn on every rule at once by extending `./node_modules/@botiverse/raft-ui/dist/lint/recommended.json` from `.oxlintrc.json` (or `configs.recommended` in an ESLint flat config); a rule added later reaches the app with the upgrade. ([#1058](https://github.com/botiverse/raft-design/pull/1058), [#1059](https://github.com/botiverse/raft-design/pull/1059))
22
+ - Why: the design system's rules should reach the apps, and the agents working in them, with raft-ui itself rather than through each repository's instructions; every region that scrolls is a ScrollArea, and nothing outside raft-ui checked it (Artea, #proj-raft-ui:#209).
23
+ - Affects: nothing until an app turns the plugin on. Then each native scroller in its code is reported: make it a ScrollArea (or `PanelScrollViewport` in a Panel), or add the comment with its reason.
24
+ - Docs: https://raft-design.botiverse.build/about/use-raft-ui/#lint-rules
25
+
26
+ ### Changed
27
+
28
+ - **useScrollAreaContainer gives its container the overflow**: the classes in `containerProps.className` make the element scroll in the `orientation`, as ScrollArea's own container does (vertical: `overflow-y: auto` and `overflow-x: hidden`; horizontal: the other way round; both: `auto`), so the element needs no overflow class of its own and nothing for `raft-ui/prefer-scroll-area` to report. `ComposerInput` and `ActivityInboxViewport` no longer carry `overflow-y-auto`. ([#1059](https://github.com/botiverse/raft-design/pull/1059))
29
+ - Why: the hook asked the app to add a native overflow class, which the new lint rule reports (Jasper's review of the lint rule).
30
+ - Affects: apps can drop the overflow class from a `useScrollAreaContainer` element. One they keep still wins (the hook's rule has no specificity), so nothing breaks. A vertical container that had only `overflow-y-auto` now clips sideways (`overflow-x: hidden`) instead of scrolling sideways with no scrollbar; pass `orientation="both"` for content that scrolls both ways.
31
+ - Docs: https://raft-design.botiverse.build/components/scroll-area/#your-own-container
32
+
33
+ ### Design System
34
+
35
+ - **A thread's timeline**: conversation.md lists what a thread's timeline shows (the message it started from and every message a person wrote), when it appears, the rail for a mouse, the Timeline entry in the thread's More menu on every device, a half-height sheet on a phone and a popover wider, and how its items are marked. No raft-ui component changed. ([#1057](https://github.com/botiverse/raft-design/pull/1057))
36
+ - Why: a long thread buries the few messages people wrote under agents' work (Artea, #proj-raft-ui:#206).
37
+ - Docs: https://raft-design.botiverse.build/patterns/conversation/#a-threads-timeline
38
+
1
39
  ## [0.73.0](https://github.com/botiverse/raft-design/compare/v0.72.0...v0.73.0) (2026-11-08)
2
40
 
3
41
  ### Changed
@@ -9,7 +9,7 @@ const activityInboxShell = tv({ slots: {
9
9
  ],
10
10
  frame: ["min-h-0 flex-1"],
11
11
  viewport: [
12
- "relative flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto *:shrink-0",
12
+ "relative flex min-h-0 flex-1 flex-col gap-0.5 *:shrink-0",
13
13
  "outline-none focus-visible:shadow-focus",
14
14
  "bg-layer-canvas px-0 pt-2 pb-[calc(8px+env(safe-area-inset-bottom,0px))]"
15
15
  ],
@@ -12,7 +12,7 @@ const composer = tv({
12
12
  inputFrame: ["w-full min-w-0", "[--r-scroll-area-resize-grip-size:0px]"],
13
13
  input: [
14
14
  "max-h-32 w-full resize-none bg-transparent outline-none",
15
- "field-sizing-content overflow-y-auto",
15
+ "field-sizing-content",
16
16
  "min-h-6 px-0.5",
17
17
  "font-sans text-body font-normal text-foreground",
18
18
  "placeholder:text-foreground-hint"
@@ -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
  });
@@ -24,8 +24,8 @@
24
24
  * `--overflow-{y,x}-{start,end}` (more content past that edge; `x` is
25
25
  * inline; these and `--rtl` are set by the script with `classList`, never
26
26
  * rendered) and `--textarea`; `__container` (with absolute gutters, the
27
- * container), `__viewport` (ScrollArea's own `<div>` container: its
28
- * overflow), `__content`, `__textarea`, `__gutters` (with `--scrolling`,
27
+ * container), `__viewport` (ScrollArea's own `<div>` container),
28
+ * `__content`, `__textarea`, `__gutters` (with `--scrolling`,
29
29
  * from the first scroll until `hideDelay` after the last, and `--dragging`,
30
30
  * both set by the script: on the layer, so toggling them restyles only the
31
31
  * gutters, not the content under the root), `__gutter` (`--vertical`,
@@ -216,9 +216,9 @@
216
216
  }
217
217
 
218
218
  /*
219
- * The container. Its own overflow, layout and focus are its own (a list,
220
- * a textarea); the overlay scrollbars replace its native ones. With sticky
221
- * gutters it is the root.
219
+ * The container. Its layout and focus are its own (a list, a textarea);
220
+ * its overflow is the orientation's (below), and the overlay scrollbars
221
+ * replace its native ones. With sticky gutters it is the root.
222
222
  */
223
223
  .r-scroll-area.r-scroll-area--sticky-gutters,
224
224
  .r-scroll-area.r-scroll-area--absolute-gutters > .r-scroll-area__container {
@@ -242,17 +242,34 @@
242
242
  position: relative;
243
243
  }
244
244
 
245
- /* ScrollArea's own `<div>` container (always sticky) scrolls in the orientation. */
246
- .r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area__viewport {
245
+ /*
246
+ * Every container scrolls in the orientation: ScrollArea's own `<div>` and
247
+ * a `useScrollAreaContainer` element alike, so a caller never writes the
248
+ * overflow. With no specificity (`:where`), so a component's own rule for
249
+ * its scroller (VirtualList's) or an overflow in the caller's `className`
250
+ * still wins over it.
251
+ */
252
+ :where(.r-scroll-area.r-scroll-area--sticky-gutters),
253
+ :where(.r-scroll-area.r-scroll-area--absolute-gutters > .r-scroll-area__container) {
247
254
  overflow: hidden auto;
248
- position: relative;
249
255
  }
250
- .r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area--horizontal-orientation.r-scroll-area__viewport {
256
+ :where(.r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area--horizontal-orientation),
257
+ :where(
258
+ .r-scroll-area.r-scroll-area--absolute-gutters.r-scroll-area--horizontal-orientation
259
+ > .r-scroll-area__container
260
+ ) {
251
261
  overflow: auto hidden;
252
262
  }
253
- .r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area--both-orientation.r-scroll-area__viewport {
263
+ :where(.r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area--both-orientation),
264
+ :where(
265
+ .r-scroll-area.r-scroll-area--absolute-gutters.r-scroll-area--both-orientation
266
+ > .r-scroll-area__container
267
+ ) {
254
268
  overflow: auto;
255
269
  }
270
+ .r-scroll-area.r-scroll-area--sticky-gutters.r-scroll-area__viewport {
271
+ position: relative;
272
+ }
256
273
 
257
274
  /*
258
275
  * A scroll that reaches the end stops there instead of scrolling the page:
@@ -116,7 +116,9 @@ export type ScrollAreaContainerProps<E extends HTMLElement = HTMLElement> = {
116
116
  ref: RefCallback<E>;
117
117
  /**
118
118
  * Before your own classes. With sticky gutters the container is the root, so these are the
119
- * root's classes and modifiers; with absolute gutters, `r-scroll-area__container`.
119
+ * root's classes and modifiers; with absolute gutters, `r-scroll-area__container`. They give the
120
+ * element the orientation's overflow (vertical: `overflow-y: auto`, `overflow-x: hidden`), so
121
+ * it needs no overflow class of its own; one of yours still wins over it.
120
122
  */
121
123
  className: string;
122
124
  /** With sticky gutters, the root's variables (`railInset`, the timelines); merge your style after it. */
@@ -6,7 +6,8 @@ import { ScrollAreaContainer, ScrollAreaContainerOptions, ScrollAreaFrameProps }
6
6
  * its scroll, and keeps the state; you render the elements.
7
7
  *
8
8
  * Spread `containerProps` on the element that scrolls (a `<ul>`, a `<textarea>`, VirtualList's
9
- * scroller through `domRef`), merging your own `className` and `style` with them (pass your own
9
+ * scroller through `domRef`), merging your own `className` and `style` with them; its classes give
10
+ * the element the orientation's overflow, so it needs no overflow class (pass your own
10
11
  * refs as `containerRef` and `frameRef`: the returned `ref`s already forward to them), and render
11
12
  * `<ScrollAreaGutters {...guttersProps} />` where `guttersPosition` says:
12
13
  *
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 };
@@ -0,0 +1,75 @@
1
+ //#region src/lint/prefer-scroll-area.d.ts
2
+ /**
3
+ * raft-ui/prefer-scroll-area: every region that scrolls is a ScrollArea
4
+ * (scroll-area.md#native-scrolling), so a native overflow that makes an
5
+ * element scroll is reported where an app writes it.
6
+ *
7
+ * Reported:
8
+ * - a native-scrolling Tailwind class (`overflow`, `overflow-x` or `overflow-y`
9
+ * set to auto or scroll, with any variant prefix, `!`, or as an arbitrary
10
+ * property) in a string or template literal that is a class: the value of a
11
+ * `className`, `class` or `*ClassName` JSX attribute, or an argument of
12
+ * `cn`, `clsx`, `cx`, `tv`, `cva`, `twMerge`, `twJoin` or `classNames`,
13
+ * through conditionals, `&&`, arrays, objects (keys and values) and `+`;
14
+ * - an inline `style={{ overflow | overflowX | overflowY: "auto" | "scroll" }}`.
15
+ *
16
+ * Allowed with a `raft-ui: native-scroll <reason>` comment, line or block, on
17
+ * the line above the reported string (or at the end of its line): it covers
18
+ * the string that starts on that line only, not the rest of the call or the
19
+ * attribute it is in, so a class added to a multi-line `cn(...)` later is
20
+ * still reported. For an inline style, the line of its property. A comment
21
+ * without a reason doesn't count.
22
+ *
23
+ * It looks only at literals, with no scope or type information: a class built
24
+ * in a variable outside these calls isn't seen.
25
+ */
26
+ export interface Position {
27
+ line: number;
28
+ column: number;
29
+ }
30
+ export interface Node {
31
+ type: string;
32
+ parent?: Node | null;
33
+ loc?: {
34
+ start: Position;
35
+ end: Position;
36
+ };
37
+ range?: [number, number];
38
+ [key: string]: unknown;
39
+ }
40
+ export interface Comment {
41
+ type: string;
42
+ value: string;
43
+ loc?: {
44
+ start: Position;
45
+ end: Position;
46
+ };
47
+ range?: [number, number];
48
+ }
49
+ export interface SourceCode {
50
+ getAllComments(): Comment[];
51
+ getLocFromIndex(index: number): Position;
52
+ }
53
+ export interface RuleContext {
54
+ sourceCode?: SourceCode;
55
+ getSourceCode?: () => SourceCode;
56
+ report(descriptor: {
57
+ node: Node;
58
+ messageId: string;
59
+ }): void;
60
+ }
61
+ export type RuleVisitor = Record<string, (node: Node) => void>;
62
+ /** An ESLint rule, the format Oxlint's JS plugins take. */
63
+ export interface RuleModule {
64
+ meta: {
65
+ type: "problem" | "suggestion" | "layout";
66
+ docs: {
67
+ description: string;
68
+ url: string;
69
+ };
70
+ messages: Record<string, string>;
71
+ schema: readonly unknown[];
72
+ };
73
+ create(context: RuleContext): RuleVisitor;
74
+ }
75
+ //#endregion
@@ -0,0 +1,157 @@
1
+ //#region src/lint/prefer-scroll-area.ts
2
+ const NATIVE_SCROLL_CLASS = /(?<![\w-])overflow(?:-[xy])?-(?:auto|scroll)(?![\w-])/;
3
+ const NATIVE_SCROLL_ARBITRARY = /\[overflow(?:-[xy])?:[^\]]*\b(?:auto|scroll)\b[^\]]*\]/;
4
+ const NATIVE_SCROLL_STYLE_KEYS = /* @__PURE__ */ new Set([
5
+ "overflow",
6
+ "overflowX",
7
+ "overflowY"
8
+ ]);
9
+ const NATIVE_SCROLL_STYLE_VALUES = /* @__PURE__ */ new Set(["auto", "scroll"]);
10
+ const CLASS_FUNCTIONS = /* @__PURE__ */ new Set([
11
+ "cn",
12
+ "clsx",
13
+ "cx",
14
+ "tv",
15
+ "cva",
16
+ "twMerge",
17
+ "twJoin",
18
+ "classNames",
19
+ "classnames"
20
+ ]);
21
+ const CLASS_ATTRIBUTE = /^(?:class|className|\w+ClassName)$/;
22
+ const PASS_THROUGH = /* @__PURE__ */ new Set([
23
+ "ArrayExpression",
24
+ "BinaryExpression",
25
+ "ConditionalExpression",
26
+ "JSXExpressionContainer",
27
+ "LogicalExpression",
28
+ "ObjectExpression",
29
+ "ParenthesizedExpression",
30
+ "Property",
31
+ "SpreadElement",
32
+ "TemplateLiteral",
33
+ "TSAsExpression",
34
+ "TSNonNullExpression",
35
+ "TSSatisfiesExpression"
36
+ ]);
37
+ const ESCAPE = /\braft-ui:\s*native-scroll\s+\S/;
38
+ /** Whether a class string has a native-scrolling class in it. */
39
+ function hasNativeScrollClass(value) {
40
+ if (!value.includes("overflow")) return false;
41
+ return NATIVE_SCROLL_CLASS.test(value) || NATIVE_SCROLL_ARBITRARY.test(value);
42
+ }
43
+ function attributeName(node) {
44
+ const name = node.name;
45
+ return name?.type === "JSXIdentifier" ? name.name : void 0;
46
+ }
47
+ /** The attribute or call that makes a string a class, or undefined when it isn't one. */
48
+ function classContext(node) {
49
+ let child = node;
50
+ let parent = node.parent;
51
+ while (parent) {
52
+ if (parent.type === "JSXAttribute") {
53
+ const name = attributeName(parent);
54
+ return name !== void 0 && CLASS_ATTRIBUTE.test(name) ? parent : void 0;
55
+ }
56
+ if (parent.type === "CallExpression") {
57
+ const callee = parent.callee;
58
+ return callee.type === "Identifier" && CLASS_FUNCTIONS.has(callee.name) && parent.arguments.includes(child) ? parent : void 0;
59
+ }
60
+ if (!PASS_THROUGH.has(parent.type)) return void 0;
61
+ child = parent;
62
+ parent = parent.parent;
63
+ }
64
+ }
65
+ function stringValue(node) {
66
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
67
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
68
+ const [quasi] = node.quasis;
69
+ const value = quasi?.value;
70
+ return value?.cooked ?? value?.raw;
71
+ }
72
+ }
73
+ /** The string literals an inline style value can be: `"auto"`, `a ? "auto" : "hidden"`, `a && "auto"`. */
74
+ function styleValueStrings(node, out) {
75
+ if (node.type === "ConditionalExpression") {
76
+ styleValueStrings(node.consequent, out);
77
+ styleValueStrings(node.alternate, out);
78
+ } else if (node.type === "LogicalExpression") {
79
+ styleValueStrings(node.left, out);
80
+ styleValueStrings(node.right, out);
81
+ } else if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") styleValueStrings(node.expression, out);
82
+ else if (stringValue(node) !== void 0) out.push(node);
83
+ return out;
84
+ }
85
+ function propertyKey(property) {
86
+ const key = property.key;
87
+ if (property.computed) return stringValue(key);
88
+ return key.type === "Identifier" ? key.name : stringValue(key);
89
+ }
90
+ const preferScrollArea = {
91
+ meta: {
92
+ type: "suggestion",
93
+ docs: {
94
+ description: "Scrolling regions use ScrollArea from @botiverse/raft-ui, not a native overflow.",
95
+ url: "https://raft-design.botiverse.build/about/use-raft-ui/#lint-rules"
96
+ },
97
+ messages: { preferScrollArea: "Scrolling regions use ScrollArea from @botiverse/raft-ui (or PanelScrollViewport inside a Panel). If this one must scroll natively, say why in an `// raft-ui: native-scroll <reason>` comment on the line above. https://raft-design.botiverse.build/components/scroll-area/" },
98
+ schema: []
99
+ },
100
+ create(context) {
101
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.();
102
+ let escapeLines;
103
+ function line(index) {
104
+ return sourceCode.getLocFromIndex(index).line;
105
+ }
106
+ /** The lines an escape comment covers: the one it ends on and the next. */
107
+ function escaped() {
108
+ if (escapeLines) return escapeLines;
109
+ escapeLines = /* @__PURE__ */ new Set();
110
+ for (const comment of sourceCode?.getAllComments() ?? []) {
111
+ if (!ESCAPE.test(comment.value)) continue;
112
+ const end = comment.loc?.end.line ?? line(comment.range[1]);
113
+ escapeLines.add(end);
114
+ escapeLines.add(end + 1);
115
+ }
116
+ return escapeLines;
117
+ }
118
+ function startLine(node) {
119
+ return node.loc?.start.line ?? line(node.range[0]);
120
+ }
121
+ /** Reports `node` unless an escape comment covers the line `anchor` starts on. */
122
+ function report(node, anchor) {
123
+ const lines = escaped();
124
+ if (lines.size > 0 && lines.has(startLine(anchor))) return;
125
+ context.report({
126
+ node,
127
+ messageId: "preferScrollArea"
128
+ });
129
+ }
130
+ function checkClassString(node, value) {
131
+ if (!hasNativeScrollClass(value)) return;
132
+ if (classContext(node)) report(node, node);
133
+ }
134
+ return {
135
+ Literal(node) {
136
+ if (typeof node.value === "string") checkClassString(node, node.value);
137
+ },
138
+ TemplateLiteral(node) {
139
+ checkClassString(node, node.quasis.map((quasi) => quasi.value.raw).join(" "));
140
+ },
141
+ JSXAttribute(node) {
142
+ if (attributeName(node) !== "style") return;
143
+ const value = node.value;
144
+ const expression = value?.type === "JSXExpressionContainer" ? value.expression : void 0;
145
+ if (expression?.type !== "ObjectExpression") return;
146
+ for (const property of expression.properties) {
147
+ if (property.type !== "Property") continue;
148
+ const key = propertyKey(property);
149
+ if (key === void 0 || !NATIVE_SCROLL_STYLE_KEYS.has(key)) continue;
150
+ for (const literal of styleValueStrings(property.value, [])) if (NATIVE_SCROLL_STYLE_VALUES.has(stringValue(literal).trim())) report(literal, property);
151
+ }
152
+ }
153
+ };
154
+ }
155
+ };
156
+ //#endregion
157
+ export { hasNativeScrollClass, preferScrollArea };
@@ -0,0 +1,6 @@
1
+ {
2
+ "jsPlugins": ["@botiverse/raft-ui/lint"],
3
+ "rules": {
4
+ "raft-ui/prefer-scroll-area": "error"
5
+ }
6
+ }
@@ -0,0 +1,22 @@
1
+ import { RuleContext, RuleModule, RuleVisitor } from "./lint/prefer-scroll-area.mjs";
2
+ //#region src/lint/index.d.ts
3
+ export interface RaftUiLintConfig {
4
+ name: string;
5
+ plugins: Record<string, RaftUiLintPlugin>;
6
+ rules: Record<string, "error" | "warn" | "off">;
7
+ }
8
+ export interface RaftUiLintPlugin {
9
+ meta: {
10
+ name: string;
11
+ };
12
+ rules: Record<string, RuleModule>;
13
+ configs: Record<string, RaftUiLintConfig>;
14
+ }
15
+ export declare const rules: Record<string, RuleModule>;
16
+ /** Every rule of the plugin with its severity, as `recommended.json` lists them. */
17
+ export declare const recommendedRules: Record<string, "error">;
18
+ declare const plugin: RaftUiLintPlugin;
19
+ /** ESLint flat config: `export default [raftUi.configs.recommended]`. */
20
+ export declare const recommended: RaftUiLintConfig;
21
+ //#endregion
22
+ export { type RuleContext, type RuleModule, type RuleVisitor, plugin as default };
package/dist/lint.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { preferScrollArea } from "./lint/prefer-scroll-area.mjs";
2
+ //#region src/lint/index.ts
3
+ /**
4
+ * raft-ui's lint plugin: the Raft Design System's rules for the code of an app
5
+ * built on raft-ui, in the ESLint plugin format, which Oxlint loads as a JS
6
+ * plugin (`jsPlugins`). Published as `@botiverse/raft-ui/lint`.
7
+ *
8
+ * Enable every rule at once by extending `recommended.json` from the same
9
+ * package (Oxlint) or adding `configs.recommended` (ESLint flat config), so a
10
+ * rule added in a later release reaches the app with the upgrade. See
11
+ * design/about/use-raft-ui.md#lint-rules.
12
+ */
13
+ const rules = { "prefer-scroll-area": preferScrollArea };
14
+ /** Every rule of the plugin with its severity, as `recommended.json` lists them. */
15
+ const recommendedRules = { "raft-ui/prefer-scroll-area": "error" };
16
+ const plugin = {
17
+ meta: { name: "raft-ui" },
18
+ rules,
19
+ configs: {}
20
+ };
21
+ /** ESLint flat config: `export default [raftUi.configs.recommended]`. */
22
+ const recommended = {
23
+ name: "raft-ui/recommended",
24
+ plugins: { "raft-ui": plugin },
25
+ rules: recommendedRules
26
+ };
27
+ plugin.configs.recommended = recommended;
28
+ //#endregion
29
+ export { plugin as default, recommended, recommendedRules, rules };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.73.0",
3
+ "version": "0.75.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -46,6 +46,11 @@
46
46
  "types": "./dist/wip.d.mts",
47
47
  "default": "./dist/wip.mjs"
48
48
  },
49
+ "./lint": {
50
+ "types": "./dist/lint.d.mts",
51
+ "default": "./dist/lint.mjs"
52
+ },
53
+ "./lint/recommended.json": "./dist/lint/recommended.json",
49
54
  "./fonts.css": "./dist/fonts.css",
50
55
  "./styles.css": "./dist/styles.css",
51
56
  "./package.json": "./package.json",
@@ -90,6 +90,7 @@ When reviewing or editing code that uses raft-ui, check:
90
90
  - [ ] Menu rows are `…Item` parts, not raw `<button>`s; raw buttons drop out of keyboard navigation
91
91
  - [ ] Product state (upload, permissions, read/unread) lives in the app; the component renders parts, not a `state` prop
92
92
  - [ ] No shadow written as `shadow-[var(--…)]`: named utility or fully literal
93
+ - [ ] Every region that scrolls is a `ScrollArea` (or `PanelScrollViewport` in a Panel), not `overflow-auto` / `overflow-scroll`; a native scroller kept on purpose has a `// raft-ui: native-scroll <reason>` comment. The `raft-ui/prefer-scroll-area` lint rule checks it: https://raft-design.botiverse.build/about/use-raft-ui.md#lint-rules
93
94
 
94
95
  ## Stability
95
96
 
@@ -19,6 +19,7 @@ description: "raft-ui's technical foundations, package contents, and integration
19
19
  | `@botiverse/raft-ui/foundation.css` | Native CSS theme variables |
20
20
  | `@botiverse/raft-ui/styles.css` | Tailwind integration |
21
21
  | `@botiverse/raft-ui/fonts.css` | Optional web fonts |
22
+ | `@botiverse/raft-ui/lint` | Lint plugin with the DS rules for apps |
22
23
 
23
24
  Component declarations live in `dist/index.d.mts` (see [API lookup](./lookup.md)). Version-matched Markdown guides and optional `raft-ui-guide` / `raft-ui-critique` skills ship in `skills/`.
24
25