Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,18 @@ Tests: `internal/theme/resolve_test.go`, `packages/ui/tests/themes.test.ts`,
and `apps/web/scripts/claude-code-theme.mjs` cover catalog parity, validation,
source colors, rendered surfaces, selection, reload and switching away.

## Native web scrollbars

Chat/reading lists, the session sidebar, workspace content, shared ScrollArea,
and dialog viewports keep native scrolling with shared theme-aware styling:
9px tracks, rounded inset thumbs, and stronger hover feedback. Firefox uses the
standard thin/color fallback; touch and forced-colors mode retain platform
defaults. No new scroller, dependency, or scroll state is introduced.

Code: `packages/ui/src/scrollbar.stylex.ts` and its viewport consumers.
Tests: `packages/ui/tests/scrollbars.mjs` (production CSS, browser geometry,
themes, keyboard/wheel, fallbacks and CSP); `WHIP/Scrollbars` in Storybook.

## Web directory navigation

The saved-session sidebar follows the compact Claude/Paper hierarchy while using
Expand Down
14 changes: 14 additions & 0 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,20 @@ backdrop. Short dialogs remain centered; tall dialogs retain the former top
spacing. Sheet and explicitly bounded picker compositions keep their own internal
scrolling. Consumers should not add wrappers to implement default dialog scrolling.

Native scrollbar visuals are shared by `@whip/ui/scrollbar.stylex`: a 9px
track with a rounded thumb, a transparent 2px inset, and theme-foreground color at
25% opacity (40% on thumb hover). Transparent tracks blend into the owning
surface. Apply `scrollbarStyles.native` to the existing scrolling element, not a
new wrapper; shared ScrollArea, dialog viewports, workspace content, the session
sidebar and ReadingList already do this. Scroll ownership, virtualization, and
chat follow/reading-position behavior stay unchanged. Fine-pointer browsers use
WebKit pseudo-elements where supported; only the fallback uses standard
`scrollbar-width/color`, since non-auto values override the pseudo-elements.
Coarse pointers and forced-colors mode keep platform defaults. Tab strips retain
intentionally hidden scrollbars. The Scrollbars story and
`npm run test:scrollbars -w @whip/ui` cover both axes, themes, hover, keyboard/wheel
scrolling, platform fallbacks, and strict CSP in production-extracted CSS.

Use `stylex.create` and `stylex.props` for authored visuals and import shared
variables from `@whip/ui/tokens.stylex`. The `.stylex` suffix is required for
cross-package static resolution. Do not add Tailwind, CSS-in-JS runtime injection,
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/reading-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from '@whip/ui';
import { ArrowDown } from 'lucide-react';
import * as stylex from '@stylexjs/stylex';
import { scale } from '@whip/ui/tokens.stylex';
import { scrollbarStyles } from '@whip/ui/scrollbar.stylex';
import { useRuntime } from './context';
import { readingTarget } from './reading-positions';
import { layout } from './styles';
Expand Down Expand Up @@ -511,7 +512,7 @@ export function ReadingList<Row extends { id: string; seq?: number }>({
)}
<div
ref={viewport}
{...stylex.props(styles.viewport, chatFollow ? styles.chatEdges : styles.topEdge)}
{...stylex.props(styles.viewport, scrollbarStyles.native, chatFollow ? styles.chatEdges : styles.topEdge)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mask fades chat scrollbar thumb

Medium Severity

scrollbarStyles.native is applied on the same ReadingList viewport as maskImage / WebkitMaskImage. Those fades clip the element’s painted output, including classic ::-webkit-scrollbar thumbs. In chat-follow mode the thumb sits in the bottom 40px fade, so the new control is weakest at the default pinned position.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 25dfcc1. Configure here.

role="region"
tabIndex={0}
aria-label={label}
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/session-sidebar.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const styles = stylex.create({
moreButton: { minHeight: 0, paddingInline: 10, color: colors.muted },
primaryDestination: { color: colors.foreground },
footer: { minHeight: 48, display: 'flex', alignItems: 'center', flexShrink: 0, borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: surface.quietBorder, marginTop: 'auto', paddingBlock: 8 },
list: { overflowAnchor: 'none', overscrollBehaviorY: 'contain', scrollbarWidth: 'thin', marginInline: -8, marginBottom: -8, paddingInline: 8, paddingBottom: 8 },
list: { overflowAnchor: 'none', overscrollBehaviorY: 'contain', marginInline: -8, marginBottom: -8, paddingInline: 8, paddingBottom: 8 },
group: { display: 'flex', alignItems: 'end', paddingBottom: 2, gap: 2 },
groupButton: { height: { default: 28, [scale.touch]: 44 }, fontSize: typography.size12, fontWeight: 500, gap: 6, paddingInline: 8, backgroundColor: { default: 'transparent', ':hover': 'transparent' } },
icon: { minHeight: { default: 28, [scale.touch]: 44 }, height: { default: 28, [scale.touch]: 44 }, width: { default: 28, [scale.touch]: 44 }, minWidth: { default: 28, [scale.touch]: 44 }, padding: 0, justifyContent: 'center', flexShrink: 0, color: surface.secondaryText },
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/session-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useQueries } from '@tanstack/react-query';
import { Button, IconButton, Menu, ContextMenu, Spinner, WhipcodeWordmark, Tooltip, Popover } from '@whip/ui';
import { Plus, Search, Settings2, Plug, ArrowUpRight, MoreHorizontal, ChevronRight, ChevronDown, Circle, Pin, MessageSquare, MessageSquareWarning, Archive, Folder, Globe, RotateCw } from 'lucide-react';
import * as stylex from '@stylexjs/stylex';
import { scrollbarStyles } from '@whip/ui/scrollbar.stylex';
import { styles, sessionMarker, directoryMarker } from './session-sidebar.stylex';
import { layout } from './styles';
import { useAppState, useRuntime, useSessionTabs } from './context';
Expand Down Expand Up @@ -42,7 +43,7 @@ export function SessionSidebar(props: SidebarProps) {
<SidebarHeader onNavigate={props.onNavigate} headerAction={props.headerAction} inset={props.inset} />
<span aria-hidden="true" data-sidebar-scroll-edge {...stylex.props(styles.scrollEdge, scrolled && styles.scrollEdgeVisible)} />
</div>
<div ref={scroll} aria-label="Saved sessions" onScroll={event => setScrolled(event.currentTarget.scrollTop > 0)} {...stylex.props(layout.sessionList, styles.list)}>
<div ref={scroll} aria-label="Saved sessions" onScroll={event => setScrolled(event.currentTarget.scrollTop > 0)} {...stylex.props(layout.sessionList, styles.list, scrollbarStyles.native)}>
<div ref={content} {...stylex.props(styles.scrollContent)}>
<nav aria-label="Main navigation" {...stylex.props(styles.destinations)}>
<button onClick={props.onSearch} {...stylex.props(styles.destination, styles.primaryDestination)}><Search size={16} />Search sessions</button>
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ in its native style path. Composition uses Base UI's `mergeProps` and `render`.
| Actions | Button, IconButton, ButtonGroup, ToggleGroup, Link, Tooltip, Kbd, CopyButton | Buttons default to `type=button`; busy controls cannot repeat actions; icon actions require a label. Clipboard errors belong to the caller. `ToggleGroup` shares secondary/ghost button styling, 2px spacing, arrow-key focus, compact `size="sm"`, and optional `multiple` selection; callers control whether an empty selection is allowed. |
| Forms | Field, Fieldset, Label, Input, Textarea, NumberField, Slider, Checkbox, RadioGroup, Switch, Select, Combobox | Field connects labels/descriptions/errors with controls. Select/Combobox use keyboard navigation and typeahead/filtering; errors remain visible. |
| Overlays | Dialog, AlertDialog, Sheet, Menu, ContextMenu, Popover, CommandPicker | Controlled open state; focus trap/return and escape/outside behavior from Base UI. Confirming asynchronous work never silently closes a dialog. |
| Structure | Tabs, Collapsible, Accordion, Separator, Stack, Row, Panel, ScrollArea, SettingsRow, Breadcrumbs, VisuallyHidden | Native scrollbars/touch scrolling; explicit selected sections. App owns navigation and virtualization. |
| Structure | Tabs, Collapsible, Accordion, Separator, Stack, Row, Panel, ScrollArea, SettingsRow, Breadcrumbs, VisuallyHidden | Native scrollbars/touch scrolling; shared theme-aware scrollbar styling on fine pointers, platform defaults on touch/forced colors. Explicit selected sections; app owns navigation and virtualization. |
| Workspace navigation | WorkspaceTabs, workspaceTabId (from `@whip/ui/workspace-tabs`) | Controlled session strip, link composition, close, pointer reorder, and leading/trailing utility slots. No session state or content loading. |
| Feedback | Badge, StatusIndicator, Progress, Meter, Spinner, Skeleton, Alert, EmptyState, ErrorState, Avatar, useToast | Status includes text, never color alone. Error/stale/loading/empty remain distinct. |
| Appearance | ThemeProvider, initializeTheme, useTheme, ThemePicker, ThemePreview | Device-local themes and bounded display preferences, reversible previews, all generated TUI themes, validated custom colors. |
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"./reset.css": "./src/reset.css",
"./fonts.css": "./src/fonts.css",
"./tokens.stylex": "./src/tokens.stylex.ts",
"./scrollbar.stylex": "./src/scrollbar.stylex.ts",
"./package.json": "./package.json",
"./theme-data": "./src/theme-data.ts"
},
Expand All @@ -30,6 +31,7 @@
"test:tabs": "node tests/workspace-tabs.mjs",
"test:layout": "node tests/workspace-layout.mjs",
"test:csp": "node tests/csp.mjs",
"test:scrollbars": "node tests/scrollbars.mjs",
"test:mermaid": "node tests/mermaid.mjs",
"test:packed": "node tests/packed.mjs",
"test:visual": "node tests/visual.mjs",
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/overlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ChevronRight, X } from 'lucide-react';
import { useRef } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { styles } from './styles.stylex';
import { scrollbarStyles } from './scrollbar.stylex';
import { useNativeOverlay } from './native-surfaces';
import { Button, Kbd } from './actions';
import type { Styled } from './actions';
Expand All @@ -17,12 +18,12 @@ import type { Option } from './forms';
export interface DialogProps extends Styled {headerXstyle?: Styled['xstyle']; bodyXstyle?: Styled['xstyle']; open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; header?: ReactNode; description?: ReactNode; children?: ReactNode; footer?: ReactNode; closeLabel?: string; initialFocus?: BaseDialog.Popup.Props['initialFocus']; finalFocus?: BaseDialog.Popup.Props['finalFocus']}
export function Dialog({open, onOpenChange, title, header, description, children, footer, closeLabel = 'Close', initialFocus, finalFocus, headerXstyle, bodyXstyle, xstyle}: DialogProps) {
const overlay = useNativeOverlay(open, onOpenChange);
return <BaseDialog.Root {...overlay}><BaseDialog.Portal><BaseDialog.Backdrop {...stylex.props(styles.backdrop)}/><BaseDialog.Viewport {...stylex.props(styles.dialogViewport)}><BaseDialog.Popup initialFocus={initialFocus} finalFocus={finalFocus} {...stylex.props(styles.dialog, xstyle)}><div {...stylex.props(styles.dialogHeader, headerXstyle)}><div {...stylex.props(styles.grow)}><BaseDialog.Title {...stylex.props(styles.dialogTitle, header != null && dialogStyles.hiddenTitle)}>{title}</BaseDialog.Title>{header}{description && <BaseDialog.Description {...stylex.props(styles.dialogDescription)}>{description}</BaseDialog.Description>}</div><BaseDialog.Close render={<Button aria-label={closeLabel} variant="ghost" xstyle={styles.icon}><X size={16}/></Button>}/></div><div {...stylex.props(styles.stack, bodyXstyle)}>{children}</div>{footer && <div {...stylex.props(styles.footer)}>{footer}</div>}</BaseDialog.Popup></BaseDialog.Viewport></BaseDialog.Portal></BaseDialog.Root>;
return <BaseDialog.Root {...overlay}><BaseDialog.Portal><BaseDialog.Backdrop {...stylex.props(styles.backdrop)}/><BaseDialog.Viewport {...stylex.props(styles.dialogViewport, scrollbarStyles.native)}><BaseDialog.Popup initialFocus={initialFocus} finalFocus={finalFocus} {...stylex.props(styles.dialog, xstyle)}><div {...stylex.props(styles.dialogHeader, headerXstyle)}><div {...stylex.props(styles.grow)}><BaseDialog.Title {...stylex.props(styles.dialogTitle, header != null && dialogStyles.hiddenTitle)}>{title}</BaseDialog.Title>{header}{description && <BaseDialog.Description {...stylex.props(styles.dialogDescription)}>{description}</BaseDialog.Description>}</div><BaseDialog.Close render={<Button aria-label={closeLabel} variant="ghost" xstyle={styles.icon}><X size={16}/></Button>}/></div><div {...stylex.props(styles.stack, bodyXstyle)}>{children}</div>{footer && <div {...stylex.props(styles.footer)}>{footer}</div>}</BaseDialog.Popup></BaseDialog.Viewport></BaseDialog.Portal></BaseDialog.Root>;
}
export function Sheet({xstyle, ...props}: DialogProps) {return <Dialog {...props} xstyle={[styles.sheet, xstyle]}/>;}
export function AlertDialog({open, onOpenChange, title, description, children, confirmLabel = 'Continue', onConfirm, loading, danger = false, finalFocus, xstyle}: Omit<DialogProps, 'footer' | 'closeLabel' | 'initialFocus'> & {confirmLabel?: string; onConfirm: () => void; loading?: boolean; danger?: boolean}) {
const overlay = useNativeOverlay(open, onOpenChange);
return <BaseAlertDialog.Root {...overlay}><BaseAlertDialog.Portal><BaseAlertDialog.Backdrop {...stylex.props(styles.backdrop)}/><BaseAlertDialog.Viewport {...stylex.props(styles.dialogViewport)}><BaseAlertDialog.Popup finalFocus={finalFocus} {...stylex.props(styles.dialog, xstyle)}><BaseAlertDialog.Title {...stylex.props(styles.dialogTitle)}>{title}</BaseAlertDialog.Title>{description && <BaseAlertDialog.Description {...stylex.props(styles.dialogDescription)}>{description}</BaseAlertDialog.Description>}{children}<div {...stylex.props(styles.footer)}><BaseAlertDialog.Close render={<Button disabled={loading}>Cancel</Button>}/><Button variant={danger ? 'danger' : 'primary'} loading={loading} onClick={onConfirm}>{confirmLabel}</Button></div></BaseAlertDialog.Popup></BaseAlertDialog.Viewport></BaseAlertDialog.Portal></BaseAlertDialog.Root>;
return <BaseAlertDialog.Root {...overlay}><BaseAlertDialog.Portal><BaseAlertDialog.Backdrop {...stylex.props(styles.backdrop)}/><BaseAlertDialog.Viewport {...stylex.props(styles.dialogViewport, scrollbarStyles.native)}><BaseAlertDialog.Popup finalFocus={finalFocus} {...stylex.props(styles.dialog, xstyle)}><BaseAlertDialog.Title {...stylex.props(styles.dialogTitle)}>{title}</BaseAlertDialog.Title>{description && <BaseAlertDialog.Description {...stylex.props(styles.dialogDescription)}>{description}</BaseAlertDialog.Description>}{children}<div {...stylex.props(styles.footer)}><BaseAlertDialog.Close render={<Button disabled={loading}>Cancel</Button>}/><Button variant={danger ? 'danger' : 'primary'} loading={loading} onClick={onConfirm}>{confirmLabel}</Button></div></BaseAlertDialog.Popup></BaseAlertDialog.Viewport></BaseAlertDialog.Portal></BaseAlertDialog.Root>;
}
export interface MenuItem {id: string; label: ReactNode; onSelect?: () => void; disabled?: boolean; danger?: boolean; shortcut?: string; icon?: ReactNode; separator?: boolean; items?: readonly MenuItem[]}
function SubMenu({item, content}: {item: MenuItem; content: ReactNode}) {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/presentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { mergeProps } from '@base-ui/react/merge-props';
import { AlertCircle, ChevronRight, X } from 'lucide-react';
import { useContext, type ComponentPropsWithRef, type ReactNode } from 'react';
import { styles } from './styles.stylex';
import { scrollbarStyles } from './scrollbar.stylex';
import { colors } from './tokens.stylex';
import { IconButton } from './actions';
import { ClipboardContext, type CopyText } from './clipboard';
Expand Down Expand Up @@ -47,7 +48,7 @@ export function Separator() {return <BaseSeparator {...stylex.props(styles.separ
export function Stack({xstyle, ...props}: ComponentPropsWithRef<'div'> & Styled) {return <div {...mergeProps(stylex.props(styles.stack, xstyle), props)}/>;}
export function Row({xstyle, ...props}: ComponentPropsWithRef<'div'> & Styled) {return <div {...mergeProps(stylex.props(styles.row, xstyle), props)}/>;}
export function Panel({xstyle, ...props}: ComponentPropsWithRef<'section'> & Styled) {return <section {...mergeProps(stylex.props(styles.panel, xstyle), props)}/>;}
export function ScrollArea({xstyle, ...props}: ComponentPropsWithRef<'div'> & Styled) {return <div tabIndex={0} {...mergeProps(stylex.props(styles.scroll, xstyle), props)}/>;}
export function ScrollArea({xstyle, ...props}: ComponentPropsWithRef<'div'> & Styled) {return <div tabIndex={0} {...mergeProps(stylex.props(styles.scroll, scrollbarStyles.native, xstyle), props)}/>;}
export function SettingsRow({label, description, children, xstyle}: {label: ReactNode; description?: ReactNode; children: ReactNode} & Styled) {return <div {...stylex.props(styles.settingsRow, xstyle)}><div {...stylex.props(styles.grow)}><div {...stylex.props(styles.label)}>{label}</div>{description && <p {...stylex.props(styles.description)}>{description}</p>}</div>{children}</div>;}
export function Breadcrumbs({children, label = 'Location'}: {children: ReactNode; label?: string}) {return <nav aria-label={label} {...stylex.props(styles.row)}>{children}</nav>;}
/**
Expand Down
45 changes: 45 additions & 0 deletions packages/ui/src/scrollbar.stylex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as stylex from '@stylexjs/stylex';
import { colors } from './tokens.stylex';

const desktop = '@media (pointer: fine) and (forced-colors: none)';

// Style the native viewport, never replace it: virtualizers and scroll intent
// handlers keep ownership. Transparent tracks/insets blend into any surface.
export const scrollbarStyles = stylex.create({
native: {
// Non-auto standard properties override WebKit pseudo-elements in Chromium.
// Use them only as the fallback; touch and forced colors stay platform-native.
scrollbarWidth: {
default: null,
'@supports not selector(::-webkit-scrollbar)': { default: null, [desktop]: 'thin' },
},
scrollbarColor: {
default: null,
'@supports not selector(::-webkit-scrollbar)': {
default: null,
[desktop]: `color-mix(in srgb, ${colors.foreground} 25%, transparent) transparent`,
},
},
'::-webkit-scrollbar': {
width: { default: null, [desktop]: 9 },
height: { default: null, [desktop]: 9 },
},
'::-webkit-scrollbar-track': {
backgroundColor: { default: null, [desktop]: 'transparent' },
},
'::-webkit-scrollbar-corner': {
backgroundColor: { default: null, [desktop]: 'transparent' },
},
'::-webkit-scrollbar-thumb': {
backgroundColor: { default: null, [desktop]: `color-mix(in srgb, ${colors.foreground} 25%, transparent)` },
backgroundClip: { default: null, [desktop]: 'padding-box' },
borderWidth: { default: null, [desktop]: 2 },
borderStyle: { default: null, [desktop]: 'solid' },
borderColor: { default: null, [desktop]: 'transparent' },
borderRadius: { default: null, [desktop]: 999 },
},
'::-webkit-scrollbar-thumb:hover': {
backgroundColor: { default: null, [desktop]: `color-mix(in srgb, ${colors.foreground} 40%, transparent)` },
},
},
});
3 changes: 2 additions & 1 deletion packages/ui/src/workspace-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useContext, useLayoutEffect, useRef, useState } from 'reac
import type { PointerEvent, ReactNode } from 'react';
import { workspaceDragContext, WorkspaceDragScope, useWorkspaceDragTarget } from './workspace-drag';
import { styles } from './workspace-layout.stylex';
import { scrollbarStyles } from './scrollbar.stylex';
import { tabDrop } from './workspace-tab-drag';
import type { TabDrop } from './workspace-tab-drag';

Expand Down Expand Up @@ -185,7 +186,7 @@ function WorkspaceLayoutContent({ layout, focusedPaneId, onResize, onFocusPane,
aria-label={labelledPanels.has(panel.id) ? undefined : panel.label} aria-labelledby={labelledPanels.has(panel.id) ? panel.labelledBy : undefined}
data-workspace-view={panel.id} data-workspace-pane={panel.paneId}
onPointerDownCapture={event => focusFromPointer(panel.paneId, event)} onFocusCapture={() => onFocusPane(panel.paneId)}
{...stylex.props(styles.content, !rect && styles.unmeasured)} style={rect}>{panel.content}</section>;
{...stylex.props(styles.content, scrollbarStyles.native, !rect && styles.unmeasured)} style={rect}>{panel.content}</section>;
})}
{scope?.visible && preview && <div aria-hidden="true" data-workspace-drop={preview.drop.edge ?? 'tab'} {...stylex.props(styles.preview)} style={preview.rect}/>}
</div>
Expand Down
Loading
Loading