diff --git a/.changeset/gentle-hoops-relax.md b/.changeset/gentle-hoops-relax.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/gentle-hoops-relax.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index fc6aba1bfc0..177d3f235ce 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -57,7 +57,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places a component in one of six layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: +`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | @@ -67,9 +67,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | `Blocks` | Reusable composite UI (e.g. `Destructive`) | C | | `Components` | Styled Mosaic components — simple CVA recipe (`Button`, `Input`) or compound/slot-based (`Dialog`, `Tabs`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | +| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | +| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | `AIO` → `Panels` → `Sections` → `Blocks` → `Components` → `Primitives` runs roughly high-level-composition → low-level-primitive. Composed layers (AIO/Panels/Sections/Blocks) are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow +archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export +actually surfaces — an argument table for a style function, a return-value table for a hook. A +`Styles` entry documents the theme tokens its atoms read, since those tokens _are_ its API; the +`Hooks` entry (`use-data-table.stories.tsx`) is `meta` alone, with no story exports at all, which is +the minimum a section entry needs. + Archetype A has two forms, chosen by whether the component exposes a single flat CVA recipe: **simple** components (`Button`, `Input`) are knob-driven; **compound** components built from slot recipes (`Dialog`, `Tabs`) have no flat variant props to knob, so they're documented like a primitive but themed. Both are detailed under Archetype A below. ### `meta` conventions (all archetypes) diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 8725a699975..08a85129e6b 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/breadcrumb'; import { Separator } from '@/components/ui/separator'; import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { getModule } from '@/lib/registry'; import { AppSidebar } from './app-sidebar'; import { ThemeToggle } from './ThemeToggle'; @@ -20,10 +21,17 @@ import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { const pathname = usePathname(); // /components/button → ["Button"] - // /primitives/dialog → ["Dialog"] - // The first segment is the group; drop it and surface the component (plus any sub-path). - const parts = pathname.split('/').filter(Boolean).slice(1); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' ')); + // /styles/scroll-area → ["Scroll Area"] + // The first segment is the group; drop it and surface the entry (plus any sub-path). + const [groupSlug, ...parts] = pathname.split('/').filter(Boolean); + + // Prefer the registry's own `meta.title`, which is the only source that round-trips a slug back + // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` → + // `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover. + return parts.map((part, index) => { + const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined; + return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase()); + }); } export function ClientRoot({ children }: { children: React.ReactNode }) { diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..9a19812fbf1 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -57,6 +57,10 @@ const docModules: Record> = { tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, + styles: { + // Atomic styles — shipped as StyleX atoms rather than components. + 'scroll-area': dynamic(() => import('../stories/scroll-area.mdx')), + }, hooks: { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 35cd5ef3ad8..eba4369ff05 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -71,10 +71,16 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {components.map(({ mod, componentSlug }) => { const href = `/${groupSlug}/${componentSlug}`; - // Hooks (e.g. `useDataTable`) are called, not rendered — show `useX()` rather - // than JSX ``. Everything else is a component. - const isHook = /^use[A-Z]/.test(mod.meta.title); - const usage = isHook ? `${mod.meta.title}()` : `<${mod.meta.title} />`; + // How an entry is USED differs by layer, so the label follows the layer rather + // than a guess at the title: hooks are called, atomic styles are a set of + // exports with no single call form worth privileging, and everything else is a + // component rendered as JSX. + const usage = + mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; return ( +### Scrolling + +`Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms +onto it, and it fades its content at whichever edge still has something to reveal. + + + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
+ {organizations} +
; +``` + +The atoms aren't specific to `Item` — they go on anything that scrolls, and they carry the edge +fades, the scrollbar, the gutter, and the theming tokens with them. See +[Scroll Area](/styles/scroll-area) for the full surface: the gutter argument, what happens when +there is nothing to scroll, the token table, and how to replace the fade entirely. + ## Usage ```tsx diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 4c63b08424d..242fb970330 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -2,6 +2,9 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { radiusVars } from '@clerk/ui/mosaic/styles'; +import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -332,3 +335,68 @@ export function Group() { ); } + +const organizations = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', +]; + +// `Item.Group` is the canonical scroll surface, so this shows the atoms doing the minimum: cap a +// height, spread them on, and the group fades its own edges. The Scroll Area page under Styles +// carries the full surface — the gutter argument, the resting state, and the theming tokens. +export function Scrolling() { + // `stylex.props()` returns a `className`, so it has to be MERGED with any class of your own + // rather than spread beside one — whichever comes last in JSX wins outright. + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {organizations.map(name => ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ))} + +
+ ); +} diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx new file mode 100644 index 00000000000..4056873218d --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -0,0 +1,256 @@ +import * as ScrollAreaStories from './scroll-area.stories'; + +# Scroll Area + +A scrolling surface that fades its content at whichever edge still has something to reveal, and +paints a scrollbar to match. It ships as **StyleX atoms rather than a component**: everything it +does is CSS, so a component would only add a DOM node and an API to version. Put the atoms on +whatever already scrolls — an `Item.Group`, a list, a panel body — and that element keeps its own +slot class, which stays the hook a theme targets. + +## Example + + + +## Usage + +`scrollAreaViewport()` returns an array, hence the `...` spread. + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
+ {organizations} +
; +``` + +`stylex.props()` returns a `className`, so a class of your own has to be **merged** with it rather +than written beside it — whichever comes last in JSX wins outright and silently drops the other: + +```tsx +const root = stylex.props(scrollAreaRoot); + +
; +``` + +## Parts + +| Export | Goes on | Description | +| ----------------------------- | --------------------- | --------------------------------------------------------------------------------- | +| `scrollAreaViewport(gutter?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | +| `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | + +`gutter` is the one argument — `auto` (the default) or `stable`, see [Gutter](#gutter). It is an +author-time decision rather than a theme one, since whether a region needs it depends on whether its +content can resize in place. + +## Examples + +### Nothing to scroll + + + +The atoms are unconditional: no "is it scrollable" branch to write, no measurement at runtime. A +scroll timeline with no scrollable overflow is inactive, so both progress vars hold at their +registered `initial-value: 0` and the mask resolves to fully opaque. Browsers without scroll-driven +animation get the same plain scrolling box rather than a broken one. + +### Gutter + + + +`auto` takes the scrollbar's space only while the content overflows; `stable` reserves it either +way. Add the rows and watch the trailing rules: `auto` jumps its content left by the lane's width as +the list starts overflowing, `stable` doesn't move. + +Two conditions must **both** hold for the two to differ at all: space-consuming scrollbars, and +content that can stop overflowing. Where the platform overlays its scrollbars they render +identically, which is why `auto` is the default. + +### Reveal on hover + + + +The far end of what `--cl-scrollbar-thumb-idle` is for — one declaration, no rules of your own: + +```css +.my-scroller { + --cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0); +} +``` + +Mosaic already dims the bar while the pointer is elsewhere; this takes that state to zero alpha. It +fades because idle → base is the one step set on the scroller, which owns the transition — see +[Styling](#styling). The lane stays reserved throughout, so nothing reflows on the way in or out. + +Reach for a zero-alpha colour rather than `transparent` in any fade like this. `transparent` is +defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the thumb +through dark, half-transparent greys and reads as dirty. Relative colour syntax +(`oklch(from … l c h / 0)`) keeps the colour's own channels and moves only the alpha. + +### Theming the scrollbar + + + +A colour per state, far louder than anything you'd ship but told apart at a glance. Move the pointer +into the region, then onto the bar, then drag it. + +Only the first of those moves animates, and the reason is structural rather than chromatic: amber → +teal changes the region's own rest colour, so it happens on the scroller, where the transition +lives. Pink and violet are the thumb's own states and switch instantly however they're written (see +[Styling](#styling)). The example stretches the transition to `0.6s` because at Mosaic's real +`0.15s` the fade is over before you've finished moving the pointer in. + +Every value is an `oklch()` literal, which is what keeps the animated step well defined — the +registered property interpolates between two colours in one space rather than guessing across +notations. + +### Shadows instead of the fade + + + +Retire the mask with `mask-image: none` and read the two per-element vars the animations write — +`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each how much that edge still +has to reveal. They live on the element carrying the atoms and inherit downward, so a pseudo-element +can drive itself from them. + +```css +.cl-item-group { + mask-image: none; +} +.cl-item-group::before, +.cl-item-group::after { + content: ''; + position: absolute; + inset-inline: 0; + height: var(--cl-scroll-fade-size); + pointer-events: none; +} +.cl-item-group::before { + top: 0; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); + transform: translateY(calc((var(--cl-scroll-area-progress-start) - 1) * var(--cl-scroll-fade-size))); +} +.cl-item-group::after { + bottom: 0; + background: linear-gradient(to top, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-end); + transform: translateY(calc((1 - var(--cl-scroll-area-progress-end)) * var(--cl-scroll-fade-size))); +} +``` + +The vars are registered as ``, so they drive position as readily as opacity: each scrim +slides out from behind its own edge as it fades in. Clip the root — `overflow: hidden` — so the half +that is still offscreen stays there. + +Mix the scrim from a theme colour rather than hardcoding black: `--cl-color-card-foreground` inverts +with the theme, so one declaration reads as a shadow on light and a soft glow on dark, where black +would vanish. + +Position such overlays absolutely against `scrollAreaRoot` — this is the case the root exists for — +rather than with `position: sticky`, which takes space in the scroll flow and reintroduces the +layout shift the mask avoids. + +## Styling + +The fade is driven by two scroll-driven animations — no scroll listener, no measurement, nothing at +runtime. It is a mask rather than a sticky overlay element, so it is paint-only and cannot shift the +content. + +These tokens are global, so setting them once retunes every scrolling surface in Mosaic: + +| Token | Default | Description | +| ----------------------------- | ------------------------ | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | +| `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | +| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour once the pointer reaches the region. | +| `--cl-scrollbar-thumb-idle` | derived from the above | Thumb colour while the pointer is elsewhere. | +| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | +| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | + +The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome +rather than content, so it should stay the same hairline whether or not the surrounding text scales. +The default is a 4px pill in an 8px lane. + +The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the +base once it reaches the region — or the content takes keyboard focus — then `hover` and `active` +for the thumb's own two. Each of the three derives from `--cl-scrollbar-thumb` rather than baking +its value in, so setting the base re-derives all of them, and any one can still be pinned on its +own. + +Only the idle → base step can animate. Blink doesn't run transitions declared on +`::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the +animating value: a change made **on the scroller** fades, a change made on the thumb itself can only +snap. `-hover` and `-active` are the thumb's own states, so they are instant by construction. + +There is no knob for nudging the thumb sideways within its lane, and it isn't an oversight. The lane +can't move — the browser places it at the inline end of the padding box, and it takes no margin, +offset, or transform — so the only lever is making the thumb's insets asymmetric. That shifts the +pill, but it also deforms it: the paint is clipped to the content box using the **inner** radius, +which CSS derives per corner as the outer radius minus that side's own border width, so unequal +insets draw the two halves of each cap with different curvature. On a 4px pill the caps stop being +round. Position the surrounding padding instead. + +Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a +thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting +it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements +neither and keeps its platform scrollbar. Everything here is gated on `@media (pointer: fine)`, so +touch platforms keep the native overlay bar they already draw. The gutter is not gated: it is a +layout decision rather than an appearance one. + +One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay +mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the +cross-platform consistency the tokens exist for, but it is a change from the platform default. To +keep the lane without the bar, take both resting colours transparent: + +```css +:root { + --cl-scrollbar-thumb: transparent; + --cl-scrollbar-thumb-idle: transparent; +} +``` + +The thumb then paints only while the pointer is on it, and faintly — `-hover` and `-active` still +derive from the base, so pin them too if you want more. It is a precise target to find, so this +works best where the fades are already carrying the signal that the region scrolls. + +One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded +surface reads as padding plus lane at the inline end. Trimming the scroller's inline-end padding to +compensate is only safe alongside `gutter: 'stable'` — with `auto` the lane is there only while the +content overflows, so the trimmed padding collapses the moment it doesn't and the rows sit flush +against the edge. Left alone, the extra lane is the safer asymmetry. + +## Accessibility + +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari +does not** (WCAG 2.1.1). `tabindex` isn't a style, so the atoms can't close that gap — set +`tabIndex={0}` yourself on a scroll surface that holds nothing focusable. A group of interactive +rows, like the examples above, needs nothing: tabbing into the content already scrolls it. diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx new file mode 100644 index 00000000000..4199aed5e9e --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -0,0 +1,325 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { radiusVars, space } from '@clerk/ui/mosaic/styles'; +import * as stylex from '@stylexjs/stylex'; +import * as React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './scroll-area.stories?raw'; + +export const meta: StoryMeta = { + group: 'Styles', + title: 'Scroll Area', + source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts', +}; + +const accounts = [ + { email: 'cameron.walker@gmail.com', organizations: ['Clerk', 'Acme Corporation', 'Globex'] }, + { email: 'cameron@clerk.com', organizations: ['Clerk', 'Initech', 'Umbrella Health'] }, + { email: 'cam@designcloud.io', organizations: ['Clerk', 'DesignCloud'] }, +]; + +function OrganizationRow({ name }: { name: string }) { + return ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ); +} + +/** + * The border is on the ROOT, not the viewport: a mask applies to the element's whole rendering, + * borders included, so a border on the viewport would fade out at the same edges its content does. + */ +export function Default() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts.map(({ email, organizations }, index) => ( + + {index > 0 ? : null} + + + {email} + + + {organizations.map(name => ( + + ))} + + ))} + +
+ ); +} + +/** The same atoms on a surface whose content fits. Nothing in the markup is conditional. */ +export function NotScrollable() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts[0].organizations.map(name => ( + + ))} + +
+ ); +} + +const gutterRows = ['Clerk', 'DesignCloud', 'Acme Corporation', 'Globex', 'Initech', 'Umbrella Health']; + +/** + * Toggling the row count is the whole demonstration: `auto` jumps its content left by the lane's + * width as the list starts overflowing, `stable` does not move. + */ +export function Gutter() { + const [scrollable, setScrollable] = React.useState(false); + const root = stylex.props(scrollAreaRoot); + const names = scrollable ? gutterRows : gutterRows.slice(0, 2); + + return ( +
+ + +
+ {(['stable', 'auto'] as const).map(gutter => ( +
+

{gutter}

+
+ + {names.map(name => ( + + + + + {name[0]} + + + + {name} + + {/* The shift is only legible against something reaching the content's right edge. */} +
+ + ))} + +
+
+ ))} +
+
+ ); +} + +const manyRows = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', + 'Massive Dynamic', + 'Aperture Science', + 'Black Mesa', + 'Oscorp', +]; + +/** + * Taking `--cl-scrollbar-thumb-idle` to zero alpha removes the bar entirely until the pointer + * reaches the region. `oklch(from … / 0)` rather than `transparent`, which is transparent BLACK and + * drags the fade through dark greys. + */ +export function HoverReveal() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {manyRows.map(name => ( + + ))} + +
+ ); +} + +/** + * A colour per state, deliberately louder than anything you'd ship. Only amber → teal animates: + * it is a change on the SCROLLER, which owns the transition. The duration is stretched well past + * Mosaic's own `base` step purely so that one step is impossible to miss. + */ +export function ThemedScrollbar() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {manyRows.map(name => ( + + ))} + +
+ ); +} + +/** + * The mask retired for overlay scrims, each reading the progress var for its edge. The scrim mixes + * from `--cl-color-card-foreground`, so it reads as a shadow on light and a glow on dark; + * hardcoded black would vanish on a dark surface. `overflow: hidden` on the root keeps the scrims + * inside its rounded corners. + * + * Each scrim slides in from behind its edge as well as fading, so the two vars drive position and + * opacity together. `overflow: hidden` on the root both rounds their corners and hides the + * offscreen half. + * + * `mask-image` is retired inline rather than from the stylesheet: swingset's dev server injects + * StyleX atoms with a specificity bump no selector of ours can outrank. The extracted production + * sheet puts them in a cascade layer, where the plain CSS below would win on its own. + */ +export function ShadowIndicators() { + const root = stylex.props(scrollAreaRoot); + + return ( + <> + +
+ + {manyRows.map(name => ( + + ))} + +
+ + ); +} diff --git a/packages/ui/src/mosaic/components/scroll-area/index.ts b/packages/ui/src/mosaic/components/scroll-area/index.ts new file mode 100644 index 00000000000..5c72aeb1986 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/index.ts @@ -0,0 +1,3 @@ +export { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +export type { ScrollAreaGutter } from './scroll-area.styles'; +export { scrollAreaVars } from './scroll-area.vars.stylex'; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts new file mode 100644 index 00000000000..1dc99a1a148 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -0,0 +1,310 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, radiusVars, scrollbarVars, scrollFadeVars, space } from '../../tokens.stylex'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; + +// Same-file locals so the `var()` references read as names rather than as a wall of +// bracket lookups inside the gradient. StyleX inlines them at build; an imported helper +// would fail static evaluation. +const progressStart = scrollAreaVars['--cl-scroll-area-progress-start']; +const progressEnd = scrollAreaVars['--cl-scroll-area-progress-end']; +const fadeSize = scrollFadeVars['--cl-scroll-fade-size']; +const fadeRange = scrollFadeVars['--cl-scroll-fade-range']; +const scrollbarWidth = scrollbarVars['--cl-scrollbar-width']; +const thumbColor = scrollbarThumbVars['--_cl-scrollbar-thumb-color']; + +// One animation per edge, each writing its own progress var. The end fade counts DOWN +// rather than running `animation-direction: reverse`: with `fill-mode: both` the two are +// equivalent (the backwards fill holds the `from` frame, so the fade reads 1 for the whole +// scroll and only drops across the final `fade-range`), and writing it into the keyframes +// keeps `animation-direction` off the element entirely. +// The suppressions work around a gap in StyleX's own types, not a problem with the CSS: +// `Keyframes` declares each frame as `CSSProperties`, which carries no index signature for +// `--*` keys, so a custom property the compiler accepts and emits correctly still fails to +// typecheck. It has to be suppressed rather than cast — the babel plugin requires a bare +// object literal, and wrapping the argument in an `as` expression fails the build with +// "keyframes() can only accept an object". A computed key is out for the same reason. +const revealStart = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-start': 0 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-start': 1 }, +}); + +const revealEnd = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-end': 1 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-end': 0 }, +}); + +// A single four-stop gradient covers both edges, because the animated quantity is a number +// the stops are computed from rather than the mask's own geometry. At progress 0 the stop +// collapses onto the edge it starts from, leaving a hard boundary that reads as fully +// opaque — so "no scroll yet" and "not scrollable at all" render identically, for free. +// +// The second layer is the scrollbar strip, held opaque so the fade never touches it. Its width +// comes from `--cl-scrollbar-width` — the lane we specify ourselves — rather than a knob of its +// own, since the two can never legitimately differ. Where we do NOT paint the scrollbar the +// layer is zero-wide and contributes nothing. Layers composite with `add` by default, so no +// `mask-composite` declaration is needed. +const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; + +// Split by concern rather than one object per slot: the sort-keys rule reorders within an +// object, so a large one ends up interleaving unrelated properties and stranding the comments +// that explain them. `scrollAreaViewport()` recomposes them, so callers spread one thing. +const styles = stylex.create({ + root: { + display: 'flex', + flexDirection: 'column', + // Only load-bearing for a future scrollbar part; the viewport needs no positioning. + position: 'relative', + // A scroll container nested in a column flex parent overflows its track without this. + minHeight: 0, + }, + + /** The scroll container itself. */ + viewport: { + overscrollBehavior: 'contain', + flexBasis: 'auto', + flexGrow: 1, + flexShrink: 1, + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + }, + + /** + * The thumb's colour, produced HERE on the scroller rather than on the pseudo-element that + * paints it, because Blink does not run transitions declared on `::-webkit-scrollbar-thumb` — + * verified by hand, and the reason Polaris declares its own on the scroller too. A registered + * custom property set here animates and inherits into the pseudo-element, which only reads it. + * + * The consequence is worth stating plainly, because it decides which states can move: a change + * made ON THE SCROLLER animates, and a change made on the thumb itself can only snap. So the + * thumb's own `:hover` / `:active` below are instant by construction, while anything driven from + * the scroller — including a consumer retargeting `--cl-scrollbar-thumb` on the region's + * `:hover` to fade the bar in — transitions through this declaration. + * + * `linear` because this is a colour: an ease on top of an already perceptually non-uniform + * interpolation only makes the midpoint drag. + */ + thumbColor: { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': { + // Quietest while the pointer is elsewhere; the region itself is the first thing that lifts + // it. `:focus-within` comes along so a keyboard user arrowing through the content gets the + // same bar a pointer user does. + default: scrollbarVars['--cl-scrollbar-thumb-idle'], + ':is(:hover, :focus-within)': scrollbarVars['--cl-scrollbar-thumb'], + }, + }, + // Longer leaving than arriving, per the duration tokens: reaching the region is direct pointer + // feedback, its decay is not. + transitionDuration: { + default: null, + '@media (pointer: fine)': { + default: durationVars['--cl-duration-base'], + ':is(:hover, :focus-within)': durationVars['--cl-duration-fast'], + }, + }, + transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, + transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, + }, + + /** + * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left + * alone, so the thumb reads as floating over the content rather than riding in a rail. + * + * Every declaration here repeats `{ default: null, '@media (pointer: fine)': … }`. A touch + * platform draws an overlay bar there is no width or colour to apply to, and — the reason the + * gate has to reach the SHAPE properties too, not just the visible ones — Blink switches an + * element to a custom scrollbar the moment ANY `::-webkit-scrollbar*` rule matches it, which + * would trade that overlay bar for a permanent one. `null` emits no declaration at all, so + * under a coarse pointer the pseudo-elements carry no rules and the platform keeps its own. + * Written out each time rather than wrapped in a local helper: the compiler evaluates a helper + * fine, but `@stylexjs/valid-styles` can't see through the call and rejects every value it + * wraps, trading this repetition for a wall of suppressions. + * + * Deliberately no `scrollbar-color` / `scrollbar-width` on the scroller: a non-`auto` value for + * either makes a UA ignore the `::-webkit-scrollbar*` family entirely, so keeping them would + * leave every rule here as dead code in exactly the engines that implement it. Firefox + * implements the pseudo-elements not at all and keeps its platform scrollbar. That is the whole + * cost of the trade, and it buys per-state thumb colours and a real pixel width, neither of + * which the standard properties can express. + * + * The thumb's states are COMBINED keys rather than a `:hover` nested inside the + * `::-webkit-scrollbar-thumb` block: StyleX emits a nested pseudo-class BEFORE the + * pseudo-element (`:hover::-webkit-scrollbar-thumb`), which asks whether the SCROLLER is + * hovered — a much larger target that lights the thumb up whenever the pointer is anywhere over + * the region. These are the thumb's own states, and a combined key is the only way to reach + * them. Their source order is the sort-keys rule's and doesn't matter: StyleX prices `:active` + * above `:hover` either way. + */ + scrollbar: { + '::-webkit-scrollbar': { + width: { default: null, '@media (pointer: fine)': scrollbarWidth }, + }, + '::-webkit-scrollbar-thumb': { + // A transparent border clipped away is how you inset a pill thumb: the lane keeps its full + // width for hit-testing while the paint shrinks to the middle of it. Both Polaris and + // `references/stylex-ui` arrive at this independently — a scrollbar pseudo-element has no + // padding to do it with. (Key order here is the sort-keys rule's, not ours.) + borderColor: { default: null, '@media (pointer: fine)': 'transparent' }, + borderRadius: { default: null, '@media (pointer: fine)': radiusVars['--cl-radius-full'] }, + borderStyle: { default: null, '@media (pointer: fine)': 'solid' }, + // Uniform, and it has to stay uniform. Nudging the pill sideways by making these asymmetric + // works geometrically but wrecks the caps: `background-clip: content-box` clips to the + // content box using the INNER radius, which CSS derives per corner as the outer radius minus + // that side's border width, so unequal borders give the two halves of each cap different + // curvature. Measured on a 4px pill, the cap goes from a mirrored `35 76 76 35` to a lopsided + // `24 60 78 54`. There is no offsetting the thumb within its lane without paying that. + borderWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, + backgroundClip: { default: null, '@media (pointer: fine)': 'content-box' }, + backgroundColor: { + default: null, + '@media (pointer: fine)': { + // eslint-disable-next-line @stylexjs/valid-styles -- valid-styles doesn't resolve a `stylex.types.color()` var to a colour; the compiler does. + default: thumbColor, + // No `scrollbar-color: auto` lever survives on this path, so forced colors need their + // own answer: pin the thumb to a system colour rather than let a themed one lose its + // contrast guarantee against a palette we no longer control. Declared on + // `background-color` rather than on the var so it holds across all four states at once. + '@media (forced-colors: active)': 'ButtonBorder', + }, + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. + '::-webkit-scrollbar-thumb:active': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-active'], + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- see above. + '::-webkit-scrollbar-thumb:hover': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-hover'], + }, + }, + // Declared transparent rather than left alone. Opting into a custom scrollbar at all means the + // track is OURS, and an undeclared one falls back to the UA's own painting for the part — + // which shows through the moment the thumb is anything less than opaque, and reads as a dark + // rail behind a thumb that was supposed to be invisible. "Unstyled" has to be said out loud. + '::-webkit-scrollbar-track': { + backgroundColor: { default: null, '@media (pointer: fine)': 'transparent' }, + }, + }, + + /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ + mask: { + maskImage, + maskPosition: 'left top, right top', + maskRepeat: 'no-repeat', + // Held back from the scrollbar only where we actually paint one, which is the same pair of + // conditions the rules above run under: a fine pointer, and an engine that implements + // `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar styling — there + // isn't one — it is the mask asking whether there is a lane to keep clear. Gecko answers no + // and gets the fade edge to edge, rather than an unfaded strip beside a bar we never styled + // and whose width we don't know. + // + // `not (-moz-appearance: none)` stands in for the question we actually want to ask, + // `selector(::-webkit-scrollbar)`, because StyleX 0.19 rewrites the argument of + // `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real + // selectors. That turns the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)`, + // which every engine reports as false — verified in Chrome, where the honest form returns + // true and the rewritten one returns false. Any property-based condition is left alone. + maskSize: { + default: '100% 100%, 0px 100%', + '@media (pointer: fine)': { + '@supports not (-moz-appearance: none)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, + }, + }, + }, + + // Only the name is gated on timeline support. A browser that ignores `animation-timeline` + // would otherwise run these on the document timeline at the default `0s` duration, land + // on the end frame immediately, and paint both fades permanently. With no name the + // remaining animation properties are inert, the vars hold at their registered + // `initial-value: 0`, and the mask resolves to fully opaque — so an unsupported browser + // gets a plain scroll area rather than a broken one. + indicators: { + // eslint-disable-next-line @stylexjs/valid-styles -- `animation-range` postdates StyleX's property allowlist; it compiles and emits correctly. + animationRange: `0px ${fadeRange}, calc(100% - ${fadeRange}) 100%`, + animationFillMode: 'both', + animationName: { + default: null, + '@supports (animation-timeline: scroll())': `${revealStart}, ${revealEnd}`, + }, + animationTimeline: 'scroll(self block), scroll(self block)', + animationTimingFunction: 'linear', + }, + + // Not focusable by default — see the `tabIndex` note on the component. Styled anyway so it + // looks right the moment a consumer opts in. + focusRing: { + outline: { default: null, ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}` }, + outlineOffset: { default: null, ':focus-visible': space['0.5'] }, + }, +}); + +// Gutter only — the scrollbar's own size is a theme token (`--cl-scrollbar-width`), since +// Mosaic has no reason to size scrollbars differently between components. What varies per +// instance is whether the space is held open, which is a layout decision about the +// surrounding content rather than an appearance one. +const gutters = stylex.create({ + // The default, and CSS's own. Nothing is reserved until a scrollbar actually appears, which + // is right whenever the content can't change height while mounted — no shift is possible, + // so holding space open would only cost width. + auto: { + scrollbarGutter: 'auto', + }, + // Opt in where the content CAN change height in place — a filterable or paginated + // collection — so crossing the overflow threshold doesn't shift the rows sideways. + stable: { + scrollbarGutter: 'stable', + }, +}); + +export type ScrollAreaGutter = keyof typeof gutters; + +/** + * The scroll surface, as StyleX atoms to spread onto an element you already render. + * + * There is no `` component: everything here is CSS, so a component would only add + * a DOM node and an API to version. Put these on whatever already scrolls — an `Item.Group`, + * a list, a panel body — and it keeps its own slot class, which stays the hook a theme + * targets. + * + * ```tsx + *
+ * {rows} + *
+ * ``` + * + * @param gutter - Whether the scrollbar's space is held open. `auto` (the default, and CSS's + * own) takes it only while the content overflows. `stable` reserves it either way, which is + * worth it when the content can change height **in place** — a filterable or paginated + * collection — so crossing the overflow threshold doesn't shift the rows sideways. Neither + * does anything on platforms that overlay their scrollbars. + */ +export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { + return [ + styles.viewport, + styles.thumbColor, + styles.scrollbar, + styles.mask, + styles.indicators, + styles.focusRing, + gutters[gutter], + ] as const; +} + +/** + * The positioned ancestor. Only needed when something has to anchor against the scroll box — + * an overlay replacing the default mask, or a future scrollbar. A scroll surface whose parent + * is already positioned doesn't need it. + */ +export const scrollAreaRoot = styles.root; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts new file mode 100644 index 00000000000..4669507d8a9 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { scrollbarVars, scrollFadeVars } from '../../tokens.stylex'; +import { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; + +describe('Mosaic scroll area styles', () => { + it('composes the viewport atoms into one spreadable set', () => { + expect(scrollAreaViewport()).toHaveLength(7); + expect(scrollAreaRoot).toBeDefined(); + }); + + // The atoms carry the gutter, so the two have to be distinguishable — an accidental + // collapse would silently give every scroll surface the same overflow behaviour. + it('varies the gutter atom by argument', () => { + expect(scrollAreaViewport('stable')).not.toEqual(scrollAreaViewport('auto')); + }); + + it('defaults the gutter to auto', () => { + expect(scrollAreaViewport()).toEqual(scrollAreaViewport('auto')); + }); + + // The `--cl-*` names are the public API — a consumer's stylesheet references them by hand, + // and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes already + // in the wild. Assert the exact strings so a rename has to be a deliberate act. + // + // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding + // a var is not itself breaking — removing or renaming one is. + it('emits the documented per-element progress properties', () => { + expect(scrollAreaVars).toMatchObject({ + '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', + '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', + }); + }); + + it('reads the shared scroll tokens', () => { + expect(scrollbarVars).toMatchObject({ + '--cl-scrollbar-width': 'var(--cl-scrollbar-width)', + '--cl-scrollbar-thumb-inset': 'var(--cl-scrollbar-thumb-inset)', + '--cl-scrollbar-thumb': 'var(--cl-scrollbar-thumb)', + '--cl-scrollbar-thumb-idle': 'var(--cl-scrollbar-thumb-idle)', + '--cl-scrollbar-thumb-hover': 'var(--cl-scrollbar-thumb-hover)', + '--cl-scrollbar-thumb-active': 'var(--cl-scrollbar-thumb-active)', + }); + expect(scrollFadeVars).toMatchObject({ + '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', + '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', + }); + }); + + // The counterpart to the assertion above: `--_cl-` is the marker for plumbing, so it must not + // drift into the themable `--cl-` namespace the way a rename easily could. + it('keeps the thumb colour carrier out of the public token namespace', () => { + expect(scrollbarThumbVars).toMatchObject({ + '--_cl-scrollbar-thumb-color': 'var(--_cl-scrollbar-thumb-color)', + }); + expect(Object.keys(scrollFadeVars)).not.toContain('--cl-scroll-fade-inset'); + }); +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts new file mode 100644 index 00000000000..ecd4f398dee --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -0,0 +1,39 @@ +import * as stylex from '@stylexjs/stylex'; + +// ScrollArea's per-element runtime output: vars a consumer READS, never sets. The scroll-driven +// animations write them on every scrolling element, so a `:root` value would simply be +// overwritten. That is why these stay component-named while the fade's actual knobs live in +// `tokens.stylex.ts` as the global `--cl-scroll-fade-*` family — those are set, these are read. +// +// They are `stylex.types.number` rather than plain strings so StyleX emits an `@property` +// registration for each. That registration is load-bearing twice over: +// +// 1. An unregistered custom property animates DISCRETELY — it would flip at 50% of the +// scroll range instead of tracking it. Registering `syntax: ""` is what makes +// the value interpolate. +// 2. `initial-value: 0` is what hides both indicators when the viewport isn't scrollable. +// A scroll timeline with no scrollable overflow is inactive, so neither animation +// applies and both vars fall back to 0 — which the mask reads as "no fade". The +// `--can-scroll` space-toggle hack the well-known demos use is unnecessary here, +// because our resting state is already the hidden one. +export const scrollAreaVars = stylex.defineVars({ + '--cl-scroll-area-progress-start': stylex.types.number(0), + '--cl-scroll-area-progress-end': stylex.types.number(0), +}); + +// The animated carrier for the scrollbar thumb's colour. `::-webkit-scrollbar-thumb` cannot +// transition properties of its own, so the transition is declared on the SCROLLER against this +// property and the pseudo-element only ever reads it — `inherits: true`, which StyleX hardcodes +// for typed vars, is what carries the animating value down into it. Same primitive as the +// progress vars above, for the same reason: unregistered, it would snap at the halfway point +// instead of interpolating. +// +// The initial value has to be a literal. `@property`'s `initial-value` must be computationally +// independent, so it cannot be the `var(--cl-scrollbar-thumb)` reference the scroller actually +// assigns — an invalid one would drop the whole registration and take the transition with it. +// +// `--_cl-` rather than `--cl-`: this is plumbing between an element and its pseudo-element, not +// a themable contract. The knobs are the `--cl-scrollbar-thumb*` tokens this resolves to. +export const scrollbarThumbVars = stylex.defineVars({ + '--_cl-scrollbar-thumb-color': stylex.types.color('transparent'), +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b6094e9f27c..826944639b8 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -28,6 +28,8 @@ export type { MenuSeparatorProps, MenuTriggerProps, } from '../components/menu'; +export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; +export type { ScrollAreaGutter } from '../components/scroll-area'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; @@ -48,6 +50,8 @@ import { easingVars, fontWeightVars, radiusVars, + scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -60,6 +64,8 @@ export { easingVars, fontWeightVars, radiusVars, + scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -74,6 +80,8 @@ export type DurationVarName = keyof typeof durationVars; export type EasingVarName = keyof typeof easingVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; +export type ScrollbarVarName = keyof typeof scrollbarVars; +export type ScrollFadeVarName = keyof typeof scrollFadeVars; export type SpacingVarName = keyof typeof spacingVars; export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 4f5ba031af9..09371e11f8b 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -87,6 +87,90 @@ const targetDefaults = { export const targetVars = stylex.defineVars(targetDefaults); +// ============================================================================= +// Scrollbar Tokens +// ============================================================================= +// One opinion for every scrolling surface in Mosaic, set in one place. Mosaic has no +// reason to render differently-sized scrollbars in different components, so these are +// tokens rather than per-component props — a consumer restyles all of them at once. +// +// The width is a real LENGTH, not the `auto | thin | none` keyword `scrollbar-width` takes. +// Mosaic paints the scrollbar through `::-webkit-scrollbar`, which takes a length; the two +// paths are mutually exclusive (a non-`auto` `scrollbar-width` or `scrollbar-color` makes a +// UA ignore the pseudo-elements outright), so specifying a length is the honest option and +// the keyword one is gone. Firefox implements neither pseudo-element and keeps its platform +// scrollbar. Set the width to `0px` to hide it outright, which is what the old `none` did. +// +// Deliberately in PIXELS rather than on the `rem` scale the rest of the tokens use. A scrollbar +// is chrome, not content: it should stay the same hairline whether or not the surrounding text +// scales, and 8px of lane carrying a 2px inset — a 4px pill with a 2px track either side — is +// a specific hairline rather than a ratio of anything. Rounding also matters more here than +// elsewhere, since the thumb is only a few pixels wide to begin with. +// +// There is deliberately no knob for nudging the thumb sideways within its lane. The lane itself +// cannot move — the browser places it at the inline end of the padding box, and it takes no margin, +// offset, or transform — so the only lever is making the thumb's insets asymmetric, and that +// visibly deforms the pill: `background-clip: content-box` clips to the inner radius, which is the +// outer radius minus each side's own border width, so unequal insets draw the two halves of every +// cap with different curvature. Tried and measured; not worth a hairline of position. +// +// The colours are FOUR states, not three, and they run from quietest to loudest: `idle` while the +// pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the +// thumb's own two. Each derives from `--cl-scrollbar-thumb` rather than baking its value in, so +// they resolve at use time — overriding the base re-derives all three, while any one stays +// individually overridable. The base is itself mixed most of the way toward `--cl-color-card`, +// which keeps a 4px bar reading as a hairline rather than a hard rule; `idle` carries on in that +// direction, and the other two step back toward `--cl-color-card-foreground`, deepening in light +// mode and lightening in dark, since that token already carries both. +// +// Only the idle → base step can animate. It is set on the scroller, which owns the transition; +// the thumb's own two are set on the pseudo-element, and Blink runs no transition there. +// +// `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` is the whole recipe +// for a scrollbar that fades in on approach: the pill paints nothing until the pointer reaches the +// region, and the lane is reserved either way, so nothing moves. +// +// Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is +// no width to apply to, and thinning a target that is already hard to hit would be actively +// worse — Polaris's `s-scroll-box` gates its scrollbar styling the same way. + +// Self-reference by literal name: the group being defined can't read its own exported object, +// and `--`-prefixed keys are emitted verbatim, so the name is stable enough to write by hand. +const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; + +const scrollbarDefaults = { + '--cl-scrollbar-width': '8px', + '--cl-scrollbar-thumb-inset': '2px', + '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`, + '--cl-scrollbar-thumb-idle': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card']} 45%)`, + '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 15%)`, + '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 30%)`, +} as const; + +export const scrollbarVars = stylex.defineVars(scrollbarDefaults); + +// The edge-fade indicator on a scrolling region. Global rather than owned by `ScrollArea` +// because "how soft is the edge of a scrolling region" is a design-language decision, on a +// par with a radius step — any component that grows an edge fade should read these rather +// than mint its own family. A component that genuinely needs a different value sets the var +// on itself; the global default still applies everywhere else. +// +// `size` and `range` default to the same value on purpose: the fade reaches full strength +// after you've scrolled its own height, so it grows in at the rate the content moves. +// +// There is deliberately no `inset` knob for holding the fade back from the scrollbar. It +// existed only to compensate for a width CSS could not measure; now that `--cl-scrollbar-width` +// specifies that width, the two can never legitimately differ — a smaller inset lets the fade +// cover part of the scrollbar, a larger one leaves an unfaded strip beside it — so the mask +// derives its inset from the scrollbar token instead of exposing a second name for the same +// number. +const scrollFadeDefaults = { + '--cl-scroll-fade-size': '1.5rem', + '--cl-scroll-fade-range': '1.5rem', +} as const; + +export const scrollFadeVars = stylex.defineVars(scrollFadeDefaults); + // ============================================================================= // Spacing Tokens // =============================================================================