From b2aebb54eb999a3608ef15975edc73359b8b9825 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 29 Jul 2026 20:07:21 -0600 Subject: [PATCH 01/11] feat(ui): add control radius and motion tokens to Mosaic Add `--cl-radius-control` (6px), a step the 4/8/12 radius progression doesn't land on, for the controls that sit between an inner mark and a container. Avatar's square shape stops hardcoding the value and points at the token. Add the motion groups the interaction styles need: `--cl-duration-*`, scaled by how directly a change answers the pointer, and a single `--cl-ease-default` for properties that actually move. Both are re-exported from `styles/index.ts` with their `*VarName` unions, matching the other token groups. --- .../mosaic/components/avatar/avatar.styles.ts | 4 +- packages/ui/src/mosaic/styles/index.ts | 15 +++++- packages/ui/src/mosaic/tokens.stylex.ts | 54 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index 782cd834ddc..bda2beb9b1f 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -43,10 +43,10 @@ export const styles = stylex.create({ }, }); -// shape — square uses a fixed 6px radius for now; circle rounds fully +// shape — square shares the control radius with Button; circle rounds fully export const shapes = stylex.create({ circle: { borderRadius: radiusVars['--cl-radius-full'] }, - square: { borderRadius: '0.375rem' }, + square: { borderRadius: radiusVars['--cl-radius-control'] }, }); // size — square box; fallback text scales with the box via inherited font-size diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 3e7c18cb3ea..43faea8e74d 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -19,14 +19,25 @@ export type { ItemProps } from '../components/item'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; -import { colorVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars } from '../tokens.stylex'; +import { + colorVars, + durationVars, + easingVars, + fontWeightVars, + radiusVars, + space, + spacingVars, + typeScaleVars, +} from '../tokens.stylex'; -export { colorVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars }; +export { colorVars, durationVars, easingVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars }; // Derived here, not in `tokens.stylex.ts`: `@stylexjs/enforce-extension` requires a // `.stylex.ts` file to export nothing but its `defineVars` results. The vars are keyed // by the same `--cl-*` names, so `keyof typeof …Vars` reproduces each token union. export type ColorVarName = keyof typeof colorVars; +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 SpacingVarName = keyof typeof spacingVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 96765b6fd73..9d47c001e4b 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -56,10 +56,16 @@ export const colorVars = stylex.defineVars(colorDefaults); // ============================================================================= // Radius Tokens // ============================================================================= +// Named by what a surface is, not by size, so the steps nest: `inner` for a mark +// sitting inside a control, `control` for the control itself (button, avatar +// square), `container` for anything wrapping controls. `control` is 6px — a step +// the 4/8/12 progression doesn't land on, which is why it's its own token rather +// than a reuse of `inner` or `element`. const radiusDefaults = { '--cl-radius-none': '0rem', '--cl-radius-inner': '0.25rem', + '--cl-radius-control': '0.375rem', '--cl-radius-element': '0.5rem', '--cl-radius-container': '0.75rem', '--cl-radius-full': 'calc(infinity * 1px)', @@ -143,3 +149,51 @@ const fontWeightDefaults = { } as const; export const fontWeightVars = stylex.defineVars(fontWeightDefaults); + +// ============================================================================= +// Motion Tokens — duration +// ============================================================================= +// Read as "how direct is this feedback": the more a change is the answer to +// something the pointer just did, the shorter it runs. `instant` is for the state +// that has to feel like contact rather than a fade — a press landing, a highlight +// appearing under the cursor; `fast` for hover and other pointer-driven state; +// `base` for that state decaying once the pointer leaves, which reads better a +// little slower than it arrived; `slow`/`slower` for changes the pointer didn't +// cause directly, like a panel or overlay resolving. +// +// Durations are not gated on `prefers-reduced-motion`. That signal is about +// vestibular safety — transforms, positional change, parallax — so the gate +// belongs on the moving property at its use site, not on every duration here. + +const durationDefaults = { + '--cl-duration-instant': '0s', + '--cl-duration-fast': '0.1s', + '--cl-duration-base': '0.15s', + '--cl-duration-slow': '0.25s', + '--cl-duration-slower': '0.35s', +} as const; + +export const durationVars = stylex.defineVars(durationDefaults); + +// ============================================================================= +// Motion Tokens — easing +// ============================================================================= +// One curve, named for its role rather than its shape so a consumer can retarget +// it without the name going stale. The default is Swift Out +// (https://www.easing.dev/swift-out, from Lochie Axon's Easing Graphs): +// front-loaded, so a change departs fast, and carrying its endpoint ~2% past +// target around 85% through before settling. +// +// It belongs on properties that MOVE — transform, translate, scale, insets — where +// the overshoot is what makes motion read as physical rather than mechanical, which +// in practice means the `slow`/`slower` end of the duration scale. Color and opacity +// take plain `linear` instead: their interpolation is already perceptually +// non-uniform, so an ease on top only makes the midpoint drag, and an overshoot +// extrapolates past the target color for no gain. That is a rule about the property, +// not the duration — a transform at `fast` still wants this curve. + +const easingDefaults = { + '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', +} as const; + +export const easingVars = stylex.defineVars(easingDefaults); From 90361e4bf3b3acbc97000f1ecdfff81f9bfde5b9 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 29 Jul 2026 20:07:26 -0600 Subject: [PATCH 02/11] feat(ui): scope Icon's data-icon selectors with a StyleX marker Icon now applies a `defineMarker()` alongside its own styles, so a container selecting on `data-icon` matches only a Mosaic `Icon` and not a stray `data-icon` elsewhere in its subtree. The marker lives in its own `.stylex.ts` module because `@stylexjs/enforce-extension` requires the define-primitives to. --- .../src/mosaic/components/icon/icon.markers.stylex.ts | 10 ++++++++++ packages/ui/src/mosaic/components/icon/icon.tsx | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/mosaic/components/icon/icon.markers.stylex.ts diff --git a/packages/ui/src/mosaic/components/icon/icon.markers.stylex.ts b/packages/ui/src/mosaic/components/icon/icon.markers.stylex.ts new file mode 100644 index 00000000000..8f989ce3ed1 --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon.markers.stylex.ts @@ -0,0 +1,10 @@ +import * as stylex from '@stylexjs/stylex'; + +/** + * Scopes a container's `data-icon` selectors to a Mosaic `Icon`, so a stray `data-icon` elsewhere + * in the subtree can't trigger them. `Icon` applies it; `Button`'s inline padding matches on it. + * + * Its own module because `@stylexjs/enforce-extension` requires the define-primitives to live in + * a `.stylex.ts` file. + */ +export const iconScope = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index d2c62aec434..4323053f184 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -5,6 +5,7 @@ import { useMosaicIcons } from '../../appearance'; import type { IconName } from '../../icons/registry'; import { iconRegistry } from '../../icons/registry'; import { mergeStyleProps, themeProps } from '../../props'; +import { iconScope } from './icon.markers.stylex'; import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { @@ -32,7 +33,7 @@ export const Icon = React.forwardRef(function MosaicIc // child by what it is: `:has([data-icon='inline-end'])` can't match some other placed descendant. const props = mergeStyleProps( themeProps('icon', { size, icon: placement }), - stylex.props(styles.base, sizes[size]), + stylex.props(styles.base, sizes[size], iconScope), className, style, ); From 66af2f27982938cc465c366a9b40c3746747ed2f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 29 Jul 2026 20:20:26 -0600 Subject: [PATCH 03/11] feat(ui): remap Mosaic Button onto color, variant, and size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `intent` axis with `color`, matching what Badge, Heading, and Text already call it, and widen every axis: `color` gains `neutral` and renames `destructive` to `negative` to track the token names; `variant` gains `link`; `size` gains `lg`. `shape` and `fullWidth` stay as orthogonal modifiers. The emitted attribute follows the prop, so `data-intent` becomes `data-color`. The variant × color cross product is flattened into one `-` map the component indexes directly, rather than one axis plumbing custom properties to the other -- vars would emit onto every instance in devtools and read as public API. Each cell is self-contained so it can be tuned against the design matrix; the values shared across cells are same-file consts that StyleX inlines. Interaction states move onto the motion tokens: an instant press so it reads as contact, a `fast` settle on release, and `linear` throughout since nothing here moves. Hover excludes `:active` explicitly rather than relying on rule order, which StyleX doesn't guarantee across at-rule boundaries. Sizes now tighten their inline padding on the side an icon sits, keyed off the `data-icon` placement Icon reflects, so an icon lands in a square cell instead of inheriting the text inset. That selector is `:has()`, which moves the Firefox build target to 121. `Button` also picks up `MosaicComponentProps`, whose `color` shadows the native `button` color attribute -- the one trigger call site forwarding native props now drops it. The `themeProps` contract tests move onto the same names, and the swingset dialog trigger drops native `color` the way the profile-section trigger does. --- .changeset/mosaic-button-variants.md | 2 + .../src/stories/destructive.stories.tsx | 2 +- .../src/stories/dialog.component.stories.tsx | 6 +- .../ui/src/mosaic/__tests__/props.test.ts | 10 +- packages/ui/src/mosaic/block/destructive.tsx | 2 +- .../mosaic/components/button/button.styles.ts | 260 +++++++++++++++--- .../mosaic/components/button/button.test.tsx | 21 +- .../src/mosaic/components/button/button.tsx | 33 +-- ...ganization-profile-delete-section.view.tsx | 2 +- ...on-profile-domains-section-remove.view.tsx | 2 +- ...anization-profile-domains-section.view.tsx | 2 +- ...rganization-profile-leave-section.view.tsx | 2 +- ...rganization-profile-members-panel.view.tsx | 2 +- ...anization-profile-profile-section.view.tsx | 2 +- packages/ui/stylex-lightningcss.config.mjs | 5 +- 15 files changed, 279 insertions(+), 74 deletions(-) create mode 100644 .changeset/mosaic-button-variants.md diff --git a/.changeset/mosaic-button-variants.md b/.changeset/mosaic-button-variants.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-button-variants.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx index 471ef75a84d..e1addc2aeee 100644 --- a/packages/swingset/src/stories/destructive.stories.tsx +++ b/packages/swingset/src/stories/destructive.stories.tsx @@ -16,7 +16,7 @@ function DestructiveTrigger(props: HTMLAttributes) { return ( diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 42c773e62c4..afe4b77b335 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -15,7 +15,9 @@ export const meta: StoryMeta = { styles: dialogRecipe, }; -const dialogTrigger = (props: React.HTMLAttributes) => ; +const dialogTrigger = ({ color: _nativeColor, ...props }: React.HTMLAttributes) => ( + +); export function Default(args: Record) { const { size } = args as { size?: 'md' | 'lg' }; @@ -29,7 +31,7 @@ export function Default(args: Record) { Confirm action Are you sure you want to proceed? This action cannot be undone. ); const button = screen.getByRole('button'); expect(button).toHaveClass('cl-button'); - expect(button).toHaveAttribute('data-intent', 'primary'); + expect(button).toHaveAttribute('data-color', 'primary'); expect(button).toHaveAttribute('data-variant', 'filled'); expect(button).toHaveAttribute('data-size', 'md'); expect(button).toHaveAttribute('data-shape', 'default'); @@ -24,7 +24,7 @@ describe('Mosaic Button', () => { it('wires variant props and consumer className/style through to the element', () => { render( , ); const button = screen.getByRole('button'); - expect(button).toHaveAttribute('data-intent', 'destructive'); + expect(button).toHaveAttribute('data-color', 'negative'); expect(button).toHaveAttribute('data-variant', 'outline'); expect(button).toHaveAttribute('data-size', 'sm'); expect(button).toHaveAttribute('data-shape', 'circle'); @@ -45,6 +45,21 @@ describe('Mosaic Button', () => { expect(button).toHaveStyle({ marginTop: '8px' }); }); + it.each(['primary', 'neutral', 'negative'] as const)('reflects the %s color', color => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-color', color); + }); + + it.each(['filled', 'outline', 'ghost', 'link'] as const)('reflects the %s variant', variant => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-variant', variant); + }); + + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-size', size); + }); + it('reflects disabled as both the native attribute and data-disabled', () => { render(); const button = screen.getByRole('button'); diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index aa38245d477..f82be82ad22 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -1,28 +1,30 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; -import { styles } from './button.styles'; +import { iconSizes, sizes, styles, variants } from './button.styles'; -export interface ButtonProps extends React.ComponentPropsWithRef<'button'> { - intent?: 'primary' | 'destructive'; - variant?: 'filled' | 'outline' | 'ghost'; - size?: 'sm' | 'md'; +export interface ButtonProps extends Omit, 'render'> { + color?: 'primary' | 'neutral' | 'negative'; + variant?: 'filled' | 'outline' | 'ghost' | 'link'; + size?: 'sm' | 'md' | 'lg'; shape?: 'default' | 'square' | 'circle'; fullWidth?: boolean; } /** * A clickable action styled by the Mosaic recipe. Renders a `button` and forwards its - * ref; `intent`, `variant`, `size`, and `shape` compose to cover the full set of styles. + * ref; `color`, `variant`, and `size` are independent axes, with `shape` and `fullWidth` + * as orthogonal modifiers. * * @example * // Default (primary, filled, md) * * * @example - * // Destructive intent with a non-filled variant - * + * // Negative color with a non-filled variant + * * * @example * // Icon-only, circular, small @@ -34,7 +36,7 @@ export interface ButtonProps extends React.ComponentPropsWithRef<'button'> { */ export const Button = React.forwardRef(function MosaicButton( { - intent = 'primary', + color = 'primary', variant = 'filled', size = 'md', shape = 'default', @@ -54,19 +56,14 @@ export const Button = React.forwardRef(function type='button' disabled={disabled} {...mergeStyleProps( - themeProps('button', { intent, variant, size, shape, fullWidth, disabled }), + themeProps('button', { color, variant, size, shape, fullWidth, disabled }), stylex.props( styles.base, - variant === 'filled' && intent === 'primary' && styles.filledPrimary, - variant === 'filled' && intent === 'destructive' && styles.filledDestructive, - variant === 'outline' && intent === 'primary' && styles.outlinePrimary, - variant === 'outline' && intent === 'destructive' && styles.outlineDestructive, - variant === 'ghost' && intent === 'primary' && styles.ghostPrimary, - variant === 'ghost' && intent === 'destructive' && styles.ghostDestructive, - size === 'sm' ? styles.sizeSm : styles.sizeMd, + sizes[size], + variants[`${variant}-${color}`], shape === 'square' && styles.shapeSquare, shape === 'circle' && styles.shapeCircle, - isIconShape && (size === 'sm' ? styles.iconSizeSm : styles.iconSizeMd), + isIconShape && iconSizes[size], fullWidth && styles.fullWidth, disabled && styles.disabled, ), diff --git a/packages/ui/src/mosaic/organization/organization-profile-delete-section.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-delete-section.view.tsx index 2c8f75d3b9e..5f6b35ea6e3 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-delete-section.view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-delete-section.view.tsx @@ -59,8 +59,8 @@ export function OrganizationProfileDeleteSectionView({ ( + + + ); +} + +export function Variants(props: Record) { + return ( +
+ + + + +
+ ); +} + +// The full variant × color matrix, laid out like the design spec: variants down, +// colors across. Every cell is the same two props, so the axes stay legible side +// by side — and hover/focus are live, since those states are what the matrix is for. +export function Colors(props: Record) { + const label = { fontSize: 12, color: '#71717a', alignSelf: 'center' } as const; + return ( +
+ + {(['primary', 'neutral', 'negative'] as const).map(color => ( + + {color} + + ))} + {(['filled', 'outline', 'ghost', 'link'] as const).map(variant => ( + + {variant} + {(['primary', 'neutral', 'negative'] as const).map(color => ( + + ))} + + ))}
); } @@ -147,6 +221,84 @@ export function Shapes(props: Record) { ); } +// `check` and `chevron-down`, not the horizontal chevrons: those draw about half the ink in the +// same box, so they read as a padding bug until the glyph set is optically normalized. +export function Icons(props: Record) { + return ( +
+ + + +
+ ); +} + +// The same three shapes at every size, so the tightened edge can be read against the +// untightened one — each row's middle button is the size's plain text padding. +export function IconSizes(props: Record) { + return ( +
+ {(['sm', 'md', 'lg'] as const).map(size => ( +
+ + + +
+ ))} +
+ ); +} + export function Disabled(props: Record) { return ( ); + const label = screen.getByRole('button').firstElementChild; + expect(label?.tagName).toBe('SPAN'); + expect(label).toHaveTextContent('Hi'); + }); + + it('leaves element children as direct children so the gap still applies', () => { + render( + , + ); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(2); + expect(button.firstElementChild).toBe(screen.getByTestId('icon')); + expect(button.lastElementChild?.tagName).toBe('SPAN'); + expect(button).toHaveAccessibleName('Hi'); + }); + it('reflects disabled as both the native attribute and data-disabled', () => { render(); const button = screen.getByRole('button'); diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index f82be82ad22..0bfa9dbd56e 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -34,6 +34,19 @@ export interface ButtonProps extends Omit, 'rende * // Full-width ghost button * */ +// Wrap the text children so they have a box of their own to truncate against — a bare text +// child is laid out in an anonymous flex item that no selector can reach. Element children +// (icons) pass through untouched, so they stay direct flex items and `gap` still applies. +function withTruncatableLabel(children: React.ReactNode) { + return React.Children.map(children, child => + typeof child === 'string' || typeof child === 'number' ? ( + {child} + ) : ( + child + ), + ); +} + export const Button = React.forwardRef(function MosaicButton( { color = 'primary', @@ -64,6 +77,8 @@ export const Button = React.forwardRef(function shape === 'square' && styles.shapeSquare, shape === 'circle' && styles.shapeCircle, isIconShape && iconSizes[size], + variant !== 'link' && styles.touchTarget, + variant !== 'link' && isIconShape && styles.touchTargetIcon, fullWidth && styles.fullWidth, disabled && styles.disabled, ), @@ -72,7 +87,7 @@ export const Button = React.forwardRef(function )} {...rest} > - {children} + {withTruncatableLabel(children)} ); }); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 43faea8e74d..643906b40b4 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -27,10 +27,21 @@ import { radiusVars, space, spacingVars, + targetVars, typeScaleVars, } from '../tokens.stylex'; -export { colorVars, durationVars, easingVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars }; +export { + colorVars, + durationVars, + easingVars, + fontWeightVars, + radiusVars, + space, + spacingVars, + targetVars, + typeScaleVars, +}; // Derived here, not in `tokens.stylex.ts`: `@stylexjs/enforce-extension` requires a // `.stylex.ts` file to export nothing but its `defineVars` results. The vars are keyed @@ -41,5 +52,6 @@ export type EasingVarName = keyof typeof easingVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; export type SpacingVarName = keyof typeof spacingVars; +export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 9d47c001e4b..4630d8e9a4f 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -73,6 +73,20 @@ const radiusDefaults = { export const radiusVars = stylex.defineVars(radiusDefaults); +// ============================================================================= +// Target Size Tokens +// ============================================================================= +// The floor a control's hit area drops to under a coarse pointer — a fingertip is +// roughly 44px across regardless of how dense the rest of the UI is. Deliberately +// off the `--cl-spacing` scale for that reason: a consumer rescaling density must +// not shrink a touch target with it. + +const targetDefaults = { + '--cl-target-coarse': '2.75rem', +} as const; + +export const targetVars = stylex.defineVars(targetDefaults); + // ============================================================================= // Spacing Tokens // ============================================================================= From 7ba7e71e9130e91013fbb1db0845f8fb2bebbcf8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 10:18:21 -0600 Subject: [PATCH 07/11] fix(ui): composite Mosaic Button's neutral fills as translucent scrims The neutral ramp -- `filled-neutral` at rest through pressed, and the hover and pressed fills every `outline-*` and `ghost-*` variant shares -- was three steps mixed off `--cl-color-neutral`. Those are opaque, so the fill painted over whatever it sat on rather than compositing with it, and a button on a non-default surface read as a neutral-colored patch instead of a lift. The three steps are now 8%/12%/16% of a fully-tilted black-on-light, white-on-dark scrim over `transparent`. Deliberately not a percentage of `--cl-color-neutral`: that token is a 900, so the same percentage of it lands lighter than the percentage of black -- 8% neutral is 0.01 L short -- and the shortfall moves with the backdrop, which would leave the step numbers describing something other than what they render. The two opaque filled fills keep blending toward their own on-fill, since they have a fill of their own to blend from; only neutral rides the opacity ramp. The outline border stays `--cl-color-border` in every state, but it no longer sits a fixed lightness gap from the fill it covers -- how close the two read now depends on the backdrop. `item` and `avatar` still mix from `--cl-color-neutral`. Unifying them with this ramp on a shared overlay token is a palette-wide change, not a Button one. Co-Authored-By: Claude Opus 5 (1M context) --- .../mosaic/components/button/button.styles.ts | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 365541a07ef..45cd65f764d 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -13,16 +13,27 @@ import { iconScope } from '../icon/icon.markers.stylex'; // Every neutral-ish fill in the matrix is one of three steps up the same gray ramp, so // the neutral family shares one set of values no matter which variant renders it: -// step 0 — `--cl-color-neutral`: `filled-neutral` at rest, `outline-*`/`ghost-*` hover +// step 0 — `filled-neutral` at rest, `outline-*`/`ghost-*` hover // step 1 — `filled-neutral` hover, `outline-*`/`ghost-*` pressed // step 2 — `filled-neutral` pressed +// +// The steps are opacities of a fully-tilted black/white scrim over `transparent`, not +// opaque values, so the fill composites against whatever it sits on. Deliberately not +// `--cl-color-neutral`: that token is a 900 (sRGB 43,43,52), so a percentage of it lands +// lighter than the same percentage of black — 8% neutral is 0.01 L short of 8% black — and +// the shortfall shifts with the backdrop, which would make the step numbers stop describing +// what they render. `item` and `avatar` still mix from `--cl-color-neutral`; unifying the +// two on a shared overlay token is a separate, palette-wide change. +// // StyleX inlines these, so no variable is emitted. They must be local bindings; an // imported one fails to compile. -const neutralStep1 = `color-mix(in oklab, ${colorVars['--cl-color-neutral']}, ${colorVars['--cl-color-neutral-foreground']} 8%)`; -const neutralStep2 = `color-mix(in oklab, ${colorVars['--cl-color-neutral']}, ${colorVars['--cl-color-neutral-foreground']} 16%)`; +const neutralStep0 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 8%, transparent)`; +const neutralStep1 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`; +const neutralStep2 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 16%, transparent)`; -// Each filled fill blends toward its own on-fill: 12%/24% for primary, 8%/16% for -// negative and for neutral, whose foreground is dark rather than light. +// The two opaque filled fills blend toward their own on-fill instead: 12%/20% for primary, +// 8%/16% for negative. Neutral can't — it has no fill of its own to blend from, so it rides +// the opacity ramp above. const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`; const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 20%)`; const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 8%)`; @@ -146,7 +157,7 @@ export const variants = stylex.create({ }, 'filled-neutral': { backgroundColor: { - default: colorVars['--cl-color-neutral'], + default: neutralStep0, ':enabled:active': neutralStep2, '@media (hover: hover)': { default: null, @@ -170,8 +181,8 @@ export const variants = stylex.create({ // The border is `--cl-color-border` in every state — it never transitions, the fill // just rises underneath it. That keeps the border opaque throughout, so it can't // alpha-fade against an incoming fill, and leaves the border independently themeable. - // It sits one step darker than the hover fill, and lands within 0.01 L of the pressed - // fill, so a pressed outline button still reads as one shape. + // The fill steps are translucent, so how close the two read depends on the backdrop + // rather than on a fixed lightness gap between them. 'outline-primary': { borderColor: colorVars['--cl-color-border'], backgroundColor: { @@ -179,7 +190,7 @@ export const variants = stylex.create({ ':enabled:active': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-neutral'], + ':enabled:hover:not(:active)': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -191,7 +202,7 @@ export const variants = stylex.create({ ':enabled:active': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-neutral'], + ':enabled:hover:not(:active)': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -203,7 +214,7 @@ export const variants = stylex.create({ ':enabled:active': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-neutral'], + ':enabled:hover:not(:active)': neutralStep0, }, }, color: colorVars['--cl-color-negative'], @@ -215,7 +226,7 @@ export const variants = stylex.create({ ':enabled:active': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-neutral'], + ':enabled:hover:not(:active)': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -226,7 +237,7 @@ export const variants = stylex.create({ ':enabled:active': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-neutral'], + ':enabled:hover:not(:active)': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], From d4ece811887f4eb8fbba334057e0060c4b012b04 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 11:31:28 -0600 Subject: [PATCH 08/11] fix(ui): retune Mosaic Button's fill ramp and share its truncation style The neutral ramp opens lower and tops out higher: 6%/12%/18% rather than 8%/12%/16%. The old rest step sat close enough to step 1 that a `filled-neutral` button barely moved on hover, and the pressed step didn't read as pressed against it. Widening the ends keeps the same three roles while making each transition visible. The opaque filled fills line up with that ramp instead of running their own spacing: primary and negative both blend 12%/18% toward their on-fill, where primary was 12%/20% and negative 8%/16%. Negative in particular was moving half as far as primary on hover for no reason other than the values predating the shared ramp. `styles.label` no longer redeclares `overflow` and `text-overflow`; the span picks up `truncationStyles.singleLine`, which is the same declaration plus the `display: block` the ellipsis needs anyway. What stays local is `minWidth: 0`, which releases the flex-item min-content floor -- that's specific to the label sitting in the button's row, and it's why `item` doesn't need the same treatment. Co-Authored-By: Claude Opus 5 (1M context) --- .../mosaic/components/button/button.styles.ts | 29 +++++++++---------- .../src/mosaic/components/button/button.tsx | 3 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 45cd65f764d..1e6cf6721f8 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -20,24 +20,23 @@ import { iconScope } from '../icon/icon.markers.stylex'; // The steps are opacities of a fully-tilted black/white scrim over `transparent`, not // opaque values, so the fill composites against whatever it sits on. Deliberately not // `--cl-color-neutral`: that token is a 900 (sRGB 43,43,52), so a percentage of it lands -// lighter than the same percentage of black — 8% neutral is 0.01 L short of 8% black — and -// the shortfall shifts with the backdrop, which would make the step numbers stop describing -// what they render. `item` and `avatar` still mix from `--cl-color-neutral`; unifying the +// lighter than the same percentage of black, and the shortfall shifts with the backdrop, +// which would make the step numbers stop describing what they render. `item` and `avatar` still mix from `--cl-color-neutral`; unifying the // two on a shared overlay token is a separate, palette-wide change. // // StyleX inlines these, so no variable is emitted. They must be local bindings; an // imported one fails to compile. -const neutralStep0 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 8%, transparent)`; +const neutralStep0 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 6%, transparent)`; const neutralStep1 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`; -const neutralStep2 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 16%, transparent)`; +const neutralStep2 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 18%, transparent)`; -// The two opaque filled fills blend toward their own on-fill instead: 12%/20% for primary, -// 8%/16% for negative. Neutral can't — it has no fill of its own to blend from, so it rides +// The two opaque filled fills blend toward their own on-fill instead, 12%/18% each. +// Neutral can't — it has no fill of its own to blend from, so it rides // the opacity ramp above. const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`; -const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 20%)`; -const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 8%)`; -const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 16%)`; +const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 18%)`; +const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; +const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; // A disabled button keeps its resting fill and only dims, so every interactive state is // gated on `:enabled`. The native `disabled` attribute blocks activation but not matching: @@ -97,13 +96,11 @@ export const styles = stylex.create({ whiteSpace: 'nowrap', }, - // The label's own box, so an over-long one ends in an ellipsis instead of a hard cut. - // It can't ride on `base`: `text-overflow` doesn't inherit, and a bare text child is laid - // out in an anonymous flex item that no selector can reach. `minWidth` releases the - // flex-item floor at `auto` (min-content), without which the box never shrinks to clip. + // The ellipsis itself comes from `truncationStyles.singleLine`; this adds the one part + // that's specific to sitting in the button's row. `minWidth` releases the flex-item floor + // at `auto` (min-content), without which the box never shrinks to clip. `item` doesn't + // need it — it releases the floor on the parent, and its text sits in a column. label: { - overflow: 'hidden', - textOverflow: 'ellipsis', minWidth: 0, }, diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 0bfa9dbd56e..b38552bc8f5 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { truncationStyles } from '../typography.styles'; import { iconSizes, sizes, styles, variants } from './button.styles'; export interface ButtonProps extends Omit, 'render'> { @@ -40,7 +41,7 @@ export interface ButtonProps extends Omit, 'rende function withTruncatableLabel(children: React.ReactNode) { return React.Children.map(children, child => typeof child === 'string' || typeof child === 'number' ? ( - {child} + {child} ) : ( child ), From ba157fae2cacdde893fb60faf6bb074cb057b023 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 12:27:24 -0600 Subject: [PATCH 09/11] fix(ui): keep a run of adjacent text in one Mosaic Button label box `withTruncatableLabel` boxed each text child on its own, so `Delete {name}` -- two children as far as React is concerned -- came out as two spans and the button's `gap` opened up mid-sentence. Adjacent text now accumulates into a single run and flushes to one span when an element child interrupts it, so the gap only ever falls between the label and an icon. The walk moved to `React.Children.toArray` so the element children it passes through keep the keys React assigns them, and the returned array doesn't warn. Children that render nothing open no box at all. Tests cover the runs, a numeric child, the empty-render case, shape reflection, absent boolean modifiers, and that a disabled button doesn't fire `onClick` -- the last one worth asserting now that `disabled` no longer sets `pointer-events: none` and the element stays hit-testable. Comments throughout `button.styles.ts` are tightened to a single pass; a TODO records that the neutral scrim scale should become shared tokens once more than Button wants those shades. Co-Authored-By: Claude Opus 5 (1M context) --- .../mosaic/components/button/button.styles.ts | 98 ++++++++----------- .../mosaic/components/button/button.test.tsx | 81 ++++++++++++++- .../src/mosaic/components/button/button.tsx | 46 +++++++-- 3 files changed, 158 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 1e6cf6721f8..59da5f26632 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -11,44 +11,39 @@ import { } from '../../tokens.stylex'; import { iconScope } from '../icon/icon.markers.stylex'; -// Every neutral-ish fill in the matrix is one of three steps up the same gray ramp, so -// the neutral family shares one set of values no matter which variant renders it: -// step 0 — `filled-neutral` at rest, `outline-*`/`ghost-*` hover +// Neutral fills are three steps up one gray ramp, shared across variants: +// step 0 — `filled-neutral` rest, `outline-*`/`ghost-*` hover // step 1 — `filled-neutral` hover, `outline-*`/`ghost-*` pressed // step 2 — `filled-neutral` pressed // -// The steps are opacities of a fully-tilted black/white scrim over `transparent`, not -// opaque values, so the fill composites against whatever it sits on. Deliberately not -// `--cl-color-neutral`: that token is a 900 (sRGB 43,43,52), so a percentage of it lands -// lighter than the same percentage of black, and the shortfall shifts with the backdrop, -// which would make the step numbers stop describing what they render. `item` and `avatar` still mix from `--cl-color-neutral`; unifying the -// two on a shared overlay token is a separate, palette-wide change. +// Scrim opacities over `transparent`, so they composite against any backdrop. Not +// `--cl-color-neutral`: it's a 900, so a percentage of it lands lighter than the same +// percentage of black by an amount that shifts with the backdrop. // -// StyleX inlines these, so no variable is emitted. They must be local bindings; an -// imported one fails to compile. +// TODO: codify this %-mix scale as shared tokens once more components adopt these +// shades — `item` and `avatar` still mix from `--cl-color-neutral`, and unifying them +// is a palette-wide change. +// +// Must be local bindings — StyleX inlines them; an imported one fails to compile. const neutralStep0 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 6%, transparent)`; const neutralStep1 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`; const neutralStep2 = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 18%, transparent)`; -// The two opaque filled fills blend toward their own on-fill instead, 12%/18% each. -// Neutral can't — it has no fill of its own to blend from, so it rides -// the opacity ramp above. +// Opaque fills blend toward their own on-fill at the same 12%/18%. Neutral has no fill +// to blend from, so it rides the opacity ramp above. const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`; const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 18%)`; const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; -// A disabled button keeps its resting fill and only dims, so every interactive state is -// gated on `:enabled`. The native `disabled` attribute blocks activation but not matching: -// `:hover` and `:active` still apply to a disabled button, and the button stays hit-testable -// so `cursor: not-allowed` renders and a wrapping tooltip still receives the pointer. +// Interactive states are gated on `:enabled`: the `disabled` attribute blocks activation but +// not matching, and the button stays hit-testable so `cursor: not-allowed` renders and a +// wrapping tooltip still gets the pointer. Disabled keeps its resting fill and only dims. // -// Hover also excludes `:active` rather than relying on the cascade. StyleX gives at-rules -// extra priority, so a `@media (hover: hover)` `:hover` outranks a bare `:active` and would -// win while pressing; `:not(:active)` stops it matching instead, which no longer depends on -// how StyleX orders equal-specificity rules. Both selectors stay written out per cell rather -// than hoisted to a const — `@stylexjs/sort-keys` reads a computed key as its identifier name -// and fails the ordering it can no longer see through. +// Hover also excludes `:active` explicitly — StyleX gives at-rules extra priority, so a +// `@media (hover: hover)` `:hover` would outrank a bare `:active` and win while pressing. +// Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys` +// reads a computed key as its identifier name and fails the ordering. export const styles = stylex.create({ base: { @@ -61,12 +56,10 @@ export const styles = stylex.create({ default: 'none', ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, }, - // The size axis fixes the height, so content that outgrows it is clipped at the edge - // rather than spilling past the button's own box. + // The size axis fixes the height, so overflowing content clips instead of spilling out. overflow: 'hidden', alignItems: 'center', - // Strips the UA's own control styling — background, border, and the platform focus ring — - // so what's below is the whole appearance rather than an override of it. + // Strips UA control styling so what's below is the whole appearance, not an override. appearance: 'none', boxSizing: 'border-box', cursor: 'pointer', @@ -77,29 +70,26 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], justifyContent: 'center', outlineOffset: '2px', - // The press is the one state that has to read as contact rather than a fade, so it - // lands with no duration at all. Releasing falls back to `fast`, since `:active` stops - // matching the moment the color starts heading back — an instant press, a soft settle. + // The press reads as contact, not a fade, so it lands instantly. Release falls back to + // `fast` — `:active` stops matching as the color heads back. Instant press, soft settle. transitionDuration: { default: durationVars['--cl-duration-fast'], ':enabled:active': durationVars['--cl-duration-instant'], }, transitionProperty: 'background-color, border-color, color, opacity', - // Linear, not `--cl-ease-default`: nothing here moves. Color interpolation is already - // perceptually non-uniform, so an ease on top only makes the midpoint drag, and the - // house curve's overshoot would extrapolate past the target color for no gain. + // Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform + // color interpolation just drags the midpoint, and the house curve's overshoot would + // extrapolate past the target color. transitionTimingFunction: 'linear', // A double-click on a button is a double-click, not a text selection. userSelect: 'none', - // A wrapped label would grow past the fixed height, so the label stays on one line and - // truncates instead (see `label`). + // A wrapped label would outgrow the fixed height, so it truncates instead (see `label`). whiteSpace: 'nowrap', }, - // The ellipsis itself comes from `truncationStyles.singleLine`; this adds the one part - // that's specific to sitting in the button's row. `minWidth` releases the flex-item floor - // at `auto` (min-content), without which the box never shrinks to clip. `item` doesn't - // need it — it releases the floor on the parent, and its text sits in a column. + // The ellipsis comes from `truncationStyles.singleLine`; this adds the part specific to + // sitting in the button's row — releasing the flex-item min-width floor, without which + // the box never shrinks enough to clip. `item` releases the floor on the parent instead. label: { minWidth: 0, }, @@ -117,10 +107,9 @@ export const styles = stylex.create({ paddingInlineStart: 0, }, - // A fingertip needs more than the visual sizes give it, so under a coarse pointer the - // control floors at the target size. Its own atom rather than a per-size override: the - // floor is one physical constant, and `link` — which is text, not a control — opts out - // by not receiving it. `minHeight` leaves the fixed height in charge everywhere else. + // Under a coarse pointer the control floors at the target size. Its own atom rather than a + // per-size override: the floor is one physical constant, and `link` — text, not a control — + // opts out by not receiving it. `minHeight` leaves the fixed height in charge otherwise. touchTarget: { minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, }, @@ -135,12 +124,11 @@ export const styles = stylex.create({ disabled: { cursor: 'not-allowed', opacity: 0.5 }, }); -// variant × color, one entry per cell of the design matrix. Each is self-contained -// so a cell can be read — and tuned — against the spec without tracing shared parts. -// Keys are `-` so the component can index them directly. +// variant × color, one entry per cell of the design matrix, keyed `-` so the +// component can index directly. Each cell is self-contained so it reads — and tunes — against +// the spec without tracing shared parts. export const variants = stylex.create({ - // The pressed state stays outside the hover media query so no-hover devices, which never - // match it, still get one. + // The pressed state stays outside the hover media query so no-hover devices still get one. 'filled-primary': { backgroundColor: { default: colorVars['--cl-color-primary'], @@ -175,11 +163,9 @@ export const variants = stylex.create({ color: colorVars['--cl-color-negative-foreground'], }, - // The border is `--cl-color-border` in every state — it never transitions, the fill - // just rises underneath it. That keeps the border opaque throughout, so it can't - // alpha-fade against an incoming fill, and leaves the border independently themeable. - // The fill steps are translucent, so how close the two read depends on the backdrop - // rather than on a fixed lightness gap between them. + // The border is `--cl-color-border` in every state — it never transitions, the fill just + // rises underneath it. Keeps the border opaque so it can't alpha-fade against an incoming + // fill, and leaves it independently themeable. 'outline-primary': { borderColor: colorVars['--cl-color-border'], backgroundColor: { @@ -239,8 +225,8 @@ export const variants = stylex.create({ }, color: colorVars['--cl-color-neutral-foreground'], }, - // negative is the one ghost that tints instead of graying, so its pressed step walks - // its own faded fill toward the negative it carries rather than joining the gray ramp. + // The one ghost that tints instead of graying, so its pressed step walks its own faded + // fill toward the negative it carries rather than joining the gray ramp. 'ghost-negative': { backgroundColor: { default: 'transparent', diff --git a/packages/ui/src/mosaic/components/button/button.test.tsx b/packages/ui/src/mosaic/components/button/button.test.tsx index effb50e481c..163b799b8bb 100644 --- a/packages/ui/src/mosaic/components/button/button.test.tsx +++ b/packages/ui/src/mosaic/components/button/button.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Button } from './button'; @@ -21,6 +22,14 @@ describe('Mosaic Button', () => { expect(button).toHaveAttribute('type', 'button'); }); + it('omits the boolean modifiers when they are off', () => { + render(); + const button = screen.getByRole('button'); + expect(button).not.toHaveAttribute('data-full-width'); + expect(button).not.toHaveAttribute('data-disabled'); + expect(button).toBeEnabled(); + }); + it('wires variant props and consumer className/style through to the element', () => { render( ); + expect(screen.getByRole('button')).toHaveAttribute('data-shape', shape); + }); + it('gives a text child its own box to truncate against', () => { render(); const label = screen.getByRole('button').firstElementChild; @@ -67,6 +81,29 @@ describe('Mosaic Button', () => { expect(label).toHaveTextContent('Hi'); }); + it('keeps a run of adjacent text in one box so the gap stays out of the sentence', () => { + render(); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(1); + expect(button.firstElementChild?.tagName).toBe('SPAN'); + expect(button).toHaveAccessibleName('Delete Alice'); + }); + + it('splits the label runs around an element child', () => { + render( + , + ); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(3); + expect(button.children[0]).toHaveTextContent('Delete'); + expect(button.children[1]).toBe(screen.getByTestId('icon')); + expect(button.children[2]).toHaveTextContent('Alice'); + }); + it('leaves element children as direct children so the gap still applies', () => { render( ); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(1); + expect(button.firstElementChild?.tagName).toBe('SPAN'); + expect(button).toHaveAccessibleName('3'); + }); + + it('opens no label box for children that render nothing', () => { + render( + , + ); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(1); + expect(button.firstElementChild).toBe(screen.getByTestId('icon')); + }); + + it('calls onClick when pressed', async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick while disabled', async () => { + const onClick = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + it('reflects disabled as both the native attribute and data-disabled', () => { render(); const button = screen.getByRole('button'); diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index b38552bc8f5..93e92d42e30 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -36,16 +36,42 @@ export interface ButtonProps extends Omit, 'rende * */ // Wrap the text children so they have a box of their own to truncate against — a bare text -// child is laid out in an anonymous flex item that no selector can reach. Element children -// (icons) pass through untouched, so they stay direct flex items and `gap` still applies. -function withTruncatableLabel(children: React.ReactNode) { - return React.Children.map(children, child => - typeof child === 'string' || typeof child === 'number' ? ( - {child} - ) : ( - child - ), - ); +// child is laid out in an anonymous flex item that no selector can reach. A whole run of +// adjacent text shares one box, or `Delete {name}` would split into two flex items with the +// button's `gap` opening up mid-sentence. Element children (icons) pass through untouched, +// so they stay direct flex items and `gap` still applies. +function withTruncatableLabel(children: React.ReactNode): React.ReactNode { + const result: React.ReactNode[] = []; + let run: React.ReactNode[] = []; + + const flushRun = () => { + if (run.length === 0) { + return; + } + result.push( + + {run} + , + ); + run = []; + }; + + // `toArray` rather than `forEach` so the elements it passes through carry the keys it + // assigns, and the array this returns doesn't warn about missing ones. + for (const child of React.Children.toArray(children)) { + if (typeof child === 'string' || typeof child === 'number') { + run.push(child); + } else { + flushRun(); + result.push(child); + } + } + flushRun(); + + return result; } export const Button = React.forwardRef(function MosaicButton( From 087286d411cec44dd08ecbe22a75d271bf78049a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 12:27:35 -0600 Subject: [PATCH 10/11] docs(swingset): document Button's truncation, disabled, and touch-target behavior Three behaviors landed on Button without prose to go with them. Truncation gets an example and a section. The point the demo has to make is that a button sizes to its own content and doesn't shrink, so most of the time there is nothing to truncate -- the ellipsis only appears once the width is constrained from outside, which the story does with `fullWidth` in a narrow container. The prose covers why only text children truncate and why an icon holds its size while the label gives way. Disabled had a story but no explanation. What matters isn't visible in it: the button keeps its resting fill rather than dropping to a gray of its own, and it stays hit-testable, which is what makes `cursor: not-allowed` render and lets a wrapping tooltip say why the action is unavailable. The coarse-pointer floor is documented under Sizes, since it's a sizing rule -- 44px on both axes for icon buttons, `link` opting out, and a note that none of it shows on a mouse, which is otherwise a confusing thing to read next to a demo that doesn't do it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/button.mdx | 30 +++++++++++++++++ .../swingset/src/stories/button.stories.tsx | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index 62cabdb3b6c..5c447778fc8 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -35,6 +35,13 @@ Button is the primary action element in Mosaic, used for form submissions and di storyModule={ButtonStories} /> +`size` sets the height, and under a coarse pointer every size floors at 44px instead — a +fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed +`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons +take the floor on both axes, so a square button stays square rather than growing tall and narrow. +`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse — +it applies at `@media (pointer: coarse)`. + ### Variants +### Truncation + +A button sizes to its own content and doesn't shrink, so most of the time there's nothing to +truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit +width — the label can't wrap to cope, because `size` fixes the height and a second line would +grow out of the button. It stays on one line and ends in an ellipsis instead. + +Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis +against, since a bare text node is laid out in an anonymous box no selector can reach. Element +children pass through as direct flex items, so an icon keeps its size and its padding while the +label gives way around it. + + + ### Disabled + +A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so +which color and variant it is stays legible while it's unavailable. Hover and press are suppressed +by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes +`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled. +That tooltip is worth adding — a disabled control with no explanation is a dead end. diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 8689217340a..03fc14af232 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -299,6 +299,38 @@ export function IconSizes(props: Record) { ); } +// `fullWidth` in a narrow container is what constrains the width — a button sizes to its own +// content otherwise (`flex-shrink: 0`), so there'd be nothing to truncate against. The icon +// keeps its size and only the label gives way. +export function Truncation(props: Record) { + return ( +
+ + + +
+ ); +} + export function Disabled(props: Record) { return (