Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ text buttons keep those hit targets while omitting the visible pill background
and radius. Use native hover, pressed, focus, disabled, and loading behavior in
addition to Design Lab's state specimens.

## Native scrollbars

`styles.css` owns scrollbar presentation inside `ThemeRoot` (or
`data-openbitfun-design-system-root`) and standalone `ScrollArea` viewports.
Native file trees, virtualized transcripts, navigation, menus, and dialogs use
the same policy without wrappers, scroll listeners, or timers.

On mouse/trackpad surfaces, the thumb appears while its own viewport is hovered
or contains visible keyboard focus. Leaving the viewport hides it; scrolling
from streaming output does not reveal an unattended panel. Touch surfaces retain
visible native thumbs, and forced colors retains system accessibility colors.
Tracks stay transparent. Only color changes, so hover never changes viewport
width, overflow, or scrollbar gutters.

`ScrollArea` keeps `scrollbarVisibility="auto"` as the default. `always` keeps
the thumb visible and reserves a scrolling track; `hidden` deliberately hides
the native scrollbar while preserving scrolling. `Menu`, `Listbox`, and
`NavigationPanelBody` forward the same contract. Product styles own layout and
`scrollbar-gutter`, not local scrollbar colors or show/hide handlers. Monaco
and terminal renderers keep their own scrollbar APIs.

## Text overflow


Expand Down Expand Up @@ -275,14 +296,18 @@ Escape or selection restores the trigger, and Tab continues from its position
in the form. Search, typed values, and multiple selection remain component-owned.
`SearchField variant="embedded"` removes its standalone pill surface for these
compositions; its container must supply padding, height, and visible focus
treatment. The default SearchField appearance is unchanged.
treatment. Standalone SearchField pills use a subtle neutral border, increasing
to the default neutral border on hover and focus while preserving validation
and forced-color states. This search-specific treatment does not change Input.

`SearchField variant="panel"` provides a joined frosted surface with a rounded
input row and an optional `footer` slot for result status and actions. It reuses
the same input node when switching from the default pill, preserves input-row
height, and provides a divider, metadata typography, and a single focus outline.
The surface uses semantic tint and blur tokens, with an opaque fallback for
unsupported blur or reduced transparency. Callers own the query, localized
The whole panel combines the raised semantic surface at 80% opacity with the
medium backdrop blur and overlay shadow; its input and footer remain transparent.
The panel uses the same quiet focus border, with an opaque fallback for
unsupported blur, reduced transparency, or high contrast. Callers own the query, localized
counts, navigation callbacks, and disabled action states; use `IconButton` for
the actions. The panel stays in normal flow by default. A toolbar that needs
downward expansion without reflow should reserve the input height and position
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@
border-radius: var(--openbitfun-layout-confirm-dialog-preview-radius);
color: var(--openbitfun-color-content-primary);
background: var(--openbitfun-color-surface-tertiary);
scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent;
scrollbar-width: thin;
}

.preview pre {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,6 @@
flex: 1 1 auto;
flex-direction: column;
overflow: auto;
scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent;
scrollbar-width: thin;
}

.body[data-inset="standard"] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
min-inline-size: 0;
min-block-size: 0;
overscroll-behavior: contain;
scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent;
scrollbar-width: thin;
}

.root[data-openbitfun-orientation="vertical"] {
Expand Down Expand Up @@ -32,41 +30,4 @@
.root[data-openbitfun-orientation="both"][data-openbitfun-scrollbar-visibility="always"] {
overflow: scroll;
}

.root::-webkit-scrollbar {
inline-size: var(--openbitfun-scrollbar-width);
block-size: var(--openbitfun-scrollbar-width);
}

.root::-webkit-scrollbar-track {
background: transparent;
}

.root::-webkit-scrollbar-thumb {
border-radius: var(--openbitfun-scrollbar-radius);
background: var(--openbitfun-color-scrollbar-thumb);
}

.root::-webkit-scrollbar-thumb:hover {
background: var(--openbitfun-color-scrollbar-thumb-hover);
}

.root[data-openbitfun-scrollbar-visibility="hidden"] {
scrollbar-width: none;
}

.root[data-openbitfun-scrollbar-visibility="hidden"]::-webkit-scrollbar {
display: none;
}

@media (forced-colors: active) {
.root {
scrollbar-color: CanvasText transparent;
}

.root::-webkit-scrollbar-thumb,
.root::-webkit-scrollbar-thumb:hover {
background: CanvasText;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ScrollAreaProps extends HTMLAttributes<HTMLDivElement> {
"data-openbitfun-component"?: string;
"data-openbitfun-part"?: string;
orientation?: ScrollAreaOrientation;
/** Auto reveals on viewport hover or keyboard focus; touch remains visible. */
scrollbarVisibility?: ScrollbarVisibility;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export const searchFieldMeta = {
"color.content.muted",
"color.content.disabled",
"color.surface.raised",
"color.surface.subtle",
"color.border.subtle",
"color.border.default",
"color.field.background",
"color.field.backgroundHover",
"color.field.border",
Expand All @@ -44,5 +44,6 @@ export const searchFieldMeta = {
"radius.pill",
"radius.lg",
"effect.blur.medium",
"shadow.overlay",
],
} as const satisfies ComponentMeta;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
border-radius: var(--openbitfun-radius-pill);
}

/* Search owns a quiet focus treatment without changing general-purpose Input. */
.root[data-variant="default"] .field:where(:not([data-invalid="true"])) {
border-color: var(--openbitfun-color-border-subtle);
}

.root[data-variant="default"] .field:where(:not([data-invalid="true"], [data-disabled="true"])):is(:hover, :focus-within) {
border-color: var(--openbitfun-color-border-default);
}

.root .clear {
/* The clear action is already composited inside Input's field surface. */
--_icon-button-background: transparent;
Expand All @@ -35,10 +44,11 @@
outline-offset: calc(-1 * var(--openbitfun-border-width-default));
color: var(--openbitfun-color-content-primary);
background: var(--openbitfun-color-surface-raised);
box-shadow: var(--openbitfun-shadow-overlay);
}

.root[data-variant="panel"]:focus-within {
outline-color: var(--openbitfun-color-field-border-focus);
outline-color: var(--openbitfun-color-border-default);
}

.root[data-variant="panel"]:has(.field[data-invalid="true"]) {
Expand Down Expand Up @@ -73,13 +83,20 @@

@supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) {
.root[data-variant="panel"] {
background: var(--openbitfun-color-surface-subtle);
/* Surface tokens may be opaque; composite the whole panel before blurring. */
background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent);
-webkit-backdrop-filter: var(--openbitfun-effect-blur-medium);
backdrop-filter: var(--openbitfun-effect-blur-medium);
}
}

@media (prefers-reduced-transparency: reduce) {
:where(:global([data-contrast="high"])) .root[data-variant="panel"] {
background: var(--openbitfun-color-surface-raised);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}

@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.root[data-variant="panel"] {
background: var(--openbitfun-color-surface-raised);
-webkit-backdrop-filter: none;
Expand All @@ -88,14 +105,26 @@
}

@media (forced-colors: active) {
.root[data-variant="panel"] {
.root[data-variant="default"] .field,
.root[data-variant="default"] .field:is(:hover, :focus-within) {
border-color: ButtonText;
}

.root[data-variant="default"] .field:focus-within {
border-color: Highlight;
}

.root[data-variant="panel"],
.root[data-variant="panel"]:has(.field[data-invalid="true"]) {
outline-color: ButtonText;
background: Canvas;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}

.root[data-variant="panel"]:focus-within {
.root[data-variant="panel"]:focus-within,
.root[data-variant="panel"]:has(.field[data-invalid="true"]):focus-within {
outline-color: Highlight;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,6 @@
-webkit-backdrop-filter: var(--openbitfun-effect-blur-base);
}

.content::-webkit-scrollbar {
inline-size: 4px;
}

.content::-webkit-scrollbar-track {
background: transparent;
}

.content::-webkit-scrollbar-thumb {
background: var(--openbitfun-color-border-default);
border-radius: 2px;
}

.arrow {
position: absolute;
z-index: 0;
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/flow-chat.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./styles/layers.css";
import "./styles/scrollbars.css";

export * from "./flow-chat/index";
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,9 @@
overflow-y: auto;
overscroll-behavior-y: contain;
padding: 0;
scrollbar-width: thin;
list-style: none;
}

.list::-webkit-scrollbar {
inline-size: var(--openbitfun-space-1);
}

.list::-webkit-scrollbar-thumb {
border-radius: var(--openbitfun-radius-pill);
background: var(--openbitfun-color-action-neutral-border);
}

.item {
box-sizing: border-box;
display: grid;
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "./styles/layers.css";
import "./styles/scrollbars.css";

export {
DesignSystemProvider,
Expand Down
83 changes: 83 additions & 0 deletions design-system/packages/ui/src/styles/scrollbars.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Native scrollbar presentation belongs to the design system. Apply it to
* ThemeRoot descendants and standalone ScrollArea viewports, including portals
* under a document-level design-system root. Each element gets its own color:
* an ancestor's hover must never reveal an idle nested viewport's scrollbar.
*
* Only the paint changes on interaction. Width, overflow, and gutter geometry
* stay fixed so virtualized lists do not remeasure when the pointer moves.
*/
@layer openbitfun.base {
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar {
width: var(--openbitfun-scrollbar-width);
height: var(--openbitfun-scrollbar-width);
background: transparent;
}

/* OS settings can expose the root, track pieces, buttons, and corner. */
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-track,
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-track-piece,
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-corner,
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-button,
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-resizer {
background: transparent;
}

:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb {
background: var(--openbitfun-color-scrollbar-thumb);
border-radius: var(--openbitfun-scrollbar-radius);
}

:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb:hover {
background: var(--openbitfun-color-scrollbar-thumb-hover);
}

/*
* Keep the standard width/color pair together. Safari 18 supports width but
* not color; setting thin there disables its transparent WebKit scrollbar.
*/
@supports (scrollbar-color: transparent transparent) {
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]) {
scrollbar-width: thin;
scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent;
}
}

/*
* Hide only for a primary mouse/trackpad. Touch keeps a discoverable native
* thumb, and forced colors keeps the operating system's accessibility cues.
* Keyboard focus reveals its viewport without latching ordinary mouse focus.
* Scroll events (including streaming output) do not reveal an idle viewport.
*/
@media (hover: hover) and (pointer: fine) and (forced-colors: none) {
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]):where(:not(:hover, :focus-visible, :has(:focus-visible), [data-openbitfun-scrollbar-visibility="always"]))::-webkit-scrollbar-thumb {
background: transparent;
}

@supports (scrollbar-color: transparent transparent) {
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]):where(:not(:hover, :focus-visible, :has(:focus-visible), [data-openbitfun-scrollbar-visibility="always"])) {
scrollbar-color: transparent transparent;
}
}
}

/* Explicit hidden viewports keep scrolling and their existing layout. */
[data-openbitfun-scrollbar-visibility="hidden"] {
scrollbar-width: none;
}

[data-openbitfun-scrollbar-visibility="hidden"]::-webkit-scrollbar {
display: none;
}

@media (forced-colors: active) {
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]) {
scrollbar-color: auto;
}

:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb,
:where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb:hover {
background: CanvasText;
}
}
}
16 changes: 11 additions & 5 deletions design-system/packages/ui/tests/scroll-area.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ test("ScrollArea styling uses public scrollbar tokens and preserves native scrol
assert.match(styles, /overflow-y: auto/);
assert.match(styles, /overflow-x: auto/);
assert.match(styles, /overflow: scroll/);
assert.match(styles, /--openbitfun-scrollbar-width/);
assert.match(styles, /--openbitfun-scrollbar-radius/);
assert.match(styles, /--openbitfun-color-scrollbar-thumb/);
assert.match(styles, /--openbitfun-color-scrollbar-thumb-hover/);
assert.match(styles, /scrollbar-width: none/);
assert.doesNotMatch(styles, /scrollbar-color:|::-webkit-scrollbar/);

// The published stylesheet must carry the shared policy for both ordinary
// native scroll containers and ScrollArea, including standalone consumers.
const publishedStyles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8");
assert.match(publishedStyles, /--openbitfun-scrollbar-width/);
assert.match(publishedStyles, /--openbitfun-scrollbar-radius/);
assert.match(publishedStyles, /--openbitfun-color-scrollbar-thumb/);
assert.match(publishedStyles, /--openbitfun-color-scrollbar-thumb-hover/);
assert.match(publishedStyles, /\[data-openbitfun-scrollbar-visibility\]/);
assert.match(publishedStyles, /scrollbar-width:\s*none/);
assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i);
});
Loading
Loading