From fa4561f2adc5ebe1ff18b8c01af02d1d3626be19 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 09:29:42 -0400 Subject: [PATCH 1/8] feat(ui): add hover state and caret to mosaic user button trigger --- .../mosaic-user-button-trigger-hover.md | 2 ++ .../__tests__/user-button.view.test.tsx | 12 +++++++++ .../mosaic/user-button/user-button.styles.ts | 25 +++++++++++++------ .../mosaic/user-button/user-button.view.tsx | 6 +++++ 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 .changeset/mosaic-user-button-trigger-hover.md diff --git a/.changeset/mosaic-user-button-trigger-hover.md b/.changeset/mosaic-user-button-trigger-hover.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-trigger-hover.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index 8d0b3b87699..c6fadbee6a0 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -865,6 +865,18 @@ describe('UserButtonTrigger', () => { expect(screen.queryByText('Pro')).toBeNull(); }); + it('ends the label with a caret, and carries none for the avatar alone', () => { + const caret = (props: Partial) => { + const { unmount } = renderTrigger(props); + const found = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-icon'); + unmount(); + return found; + }; + + expect(caret({ mode: 'organization' })).not.toBeNull(); + expect(caret({ mode: 'organization', renderTriggerLabel: false })).toBeNull(); + }); + it('takes its corner from the workspace it names, labelled or not', () => { const corner = (props: Partial) => { const { unmount } = renderTrigger(props); diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index b08f0b7e023..15dfc22f954 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -1,25 +1,30 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; +import { colorVars, durationVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; export const styles = stylex.create({ accountIdentifier: { fontWeight: fontWeightVars['--cl-font-medium'], }, - // The avatar is the trigger, so the button paints nothing of its own. trigger: { - padding: 0, + padding: space['1'], borderStyle: 'none', - backgroundColor: 'transparent', + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':is([data-open])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + }, + }, cursor: 'pointer', display: 'inline-flex', + transitionDuration: durationVars['--cl-duration-base'], + transitionProperty: 'background-color', }, - // A labelled trigger sits in a host app's chrome, so it stays flush like the avatar-only form - // and only spaces the avatar from its text. triggerLabelled: { - gap: space['2'], - alignItems: 'center', + gap: space['1.5'], }, // Matches `Item.Label`, so the trigger names a workspace the same way its row does. Capped, @@ -32,6 +37,10 @@ export const styles = stylex.create({ maxWidth: '12rem', }, + triggerCaret: { + '--_cl-icon-color': colorVars['--cl-color-neutral-faded'], + }, + // The workspace list scrolls; the header and footer stay put. The scroll area carries the // overflow, the edge fades, the scrollbar and the scroll padding they need, so only the cap // is ours. diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index 0be85e39744..3fde6cbcc25 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1092,6 +1092,12 @@ export function UserButtonTrigger({ <> {name} {planLabel ? {planLabel} : null} + ) : null} From a401669e76ea256c667d18dc7f5d8ad4e0948c55 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 09:35:44 -0400 Subject: [PATCH 2/8] feat(ui): make the trigger corner concentric with the avatar --- packages/ui/src/mosaic/user-button/user-button.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index 15dfc22f954..aff0252e41d 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -61,5 +61,5 @@ export const styles = stylex.create({ // an organization. Rounding it fully would draw a circle around a square mark, labelled or not. export const triggerShapes = stylex.create({ circle: { borderRadius: radiusVars['--cl-radius-full'] }, - square: { borderRadius: radiusVars['--cl-radius-md'] }, + square: { borderRadius: `calc(${radiusVars['--cl-radius-md']} + ${space['1']})` }, }); From a0f71b54e5585755cba269f58ec5b3cb41c92481 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 10:24:19 -0400 Subject: [PATCH 3/8] revert(ui): drop the concentric trigger corner --- packages/ui/src/mosaic/user-button/user-button.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index aff0252e41d..15dfc22f954 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -61,5 +61,5 @@ export const styles = stylex.create({ // an organization. Rounding it fully would draw a circle around a square mark, labelled or not. export const triggerShapes = stylex.create({ circle: { borderRadius: radiusVars['--cl-radius-full'] }, - square: { borderRadius: `calc(${radiusVars['--cl-radius-md']} + ${space['1']})` }, + square: { borderRadius: radiusVars['--cl-radius-md'] }, }); From a29a2f9bc04e031c82d388ff8c4e229802693ede Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 13:34:55 -0400 Subject: [PATCH 4/8] add extra spacing to the carret --- packages/ui/src/mosaic/user-button/user-button.styles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index 15dfc22f954..7c638ca39ea 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -39,6 +39,7 @@ export const styles = stylex.create({ triggerCaret: { '--_cl-icon-color': colorVars['--cl-color-neutral-faded'], + marginInlineEnd: space['1'], }, // The workspace list scrolls; the header and footer stay put. The scroll area carries the From 93a4b6c134a8602669acd4db71bfe940f71dc912 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 19:46:37 -0400 Subject: [PATCH 5/8] feat(ui): size the trigger at 32px across both variants --- .../__tests__/user-button.view.test.tsx | 20 ++++++++++++++----- .../mosaic/user-button/user-button.styles.ts | 11 +++------- .../mosaic/user-button/user-button.view.tsx | 11 +++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index c6fadbee6a0..c28d5f2e14d 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -877,7 +877,20 @@ describe('UserButtonTrigger', () => { expect(caret({ mode: 'organization', renderTriggerLabel: false })).toBeNull(); }); - it('takes its corner from the workspace it names, labelled or not', () => { + it('shrinks the avatar beside a label, and fills the trigger with it alone', () => { + const avatarSize = (props: Partial) => { + const { unmount } = renderTrigger(props); + const avatar = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-avatar'); + const size = avatar?.getAttribute('data-size'); + unmount(); + return size; + }; + + expect(avatarSize({ mode: 'organization' })).toBe('xs'); + expect(avatarSize({ mode: 'organization', renderTriggerLabel: false })).toBe('sm'); + }); + + it('keeps the same corner for a user and an organization', () => { const corner = (props: Partial) => { const { unmount } = renderTrigger(props); const className = screen.getByRole('button', { name: /Open account menu/ }).className; @@ -885,10 +898,7 @@ describe('UserButtonTrigger', () => { return className; }; - expect(corner({ mode: 'organization' })).not.toEqual(corner({ mode: 'user' })); - expect(corner({ mode: 'organization', renderTriggerLabel: false })).not.toEqual( - corner({ mode: 'user', renderTriggerLabel: false }), - ); + expect(corner({ mode: 'organization' })).toEqual(corner({ mode: 'user' })); }); it('names the active organization before its membership list has loaded', () => { diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index 7c638ca39ea..0a4e9246f88 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -7,7 +7,8 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], }, trigger: { - padding: space['1'], + padding: 0, + borderRadius: radiusVars['--cl-radius-md'], borderStyle: 'none', alignItems: 'center', backgroundColor: { @@ -24,6 +25,7 @@ export const styles = stylex.create({ }, triggerLabelled: { + padding: space['1'], gap: space['1.5'], }, @@ -57,10 +59,3 @@ export const styles = stylex.create({ width: space['7'], }, }); - -// The trigger takes the corner of the workspace mark it carries: round for a person, squared for -// an organization. Rounding it fully would draw a circle around a square mark, labelled or not. -export const triggerShapes = stylex.create({ - circle: { borderRadius: radiusVars['--cl-radius-full'] }, - square: { borderRadius: radiusVars['--cl-radius-md'] }, -}); diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index 3fde6cbcc25..2bbcee603ba 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -24,7 +24,7 @@ import { truncationStyles } from '../utils/typography.styles'; import type { UserButtonLayout } from './user-button.layout'; import { resolveUserButtonLayout } from './user-button.layout'; import { fill, plural, userButtonBase as m } from './user-button.messages'; -import { styles, triggerShapes } from './user-button.styles'; +import { styles } from './user-button.styles'; import type { UserButtonBrandingProps, UserButtonBusyState, @@ -1075,18 +1075,13 @@ export function UserButtonTrigger({ return ( {renderTriggerLabel ? ( <> From 46167ae978140018b79d53c854519e50924a1963 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 20:07:52 -0400 Subject: [PATCH 6/8] feat(ui): round the avatar-only user trigger fully --- packages/swingset/src/stories/user-button.mdx | 7 +++++++ .../swingset/src/stories/user-button.stories.tsx | 12 ++++++++++++ .../user-button/__tests__/user-button.view.test.tsx | 5 ++++- .../ui/src/mosaic/user-button/user-button.styles.ts | 4 ++++ .../ui/src/mosaic/user-button/user-button.view.tsx | 7 ++++++- 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index f32bbdc1671..9d0d40f5f59 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -83,6 +83,13 @@ trigger, no selection when personal is hidden and none is active, the account ot storyModule={UserButtonStories} /> +A user's avatar on its own rounds the trigger fully to match it. + + + `renderTriggerBadge={false}` keeps the name. The badge is part of the label, so it needs both. ) { ); } +export function UserAvatarOnly(_args: Record) { + const prototype = usePrototype(); + + return ( + + ); +} + export function WithoutTriggerBadge(_args: Record) { const prototype = usePrototype(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index c28d5f2e14d..e2fbecf1ac4 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -890,7 +890,7 @@ describe('UserButtonTrigger', () => { expect(avatarSize({ mode: 'organization', renderTriggerLabel: false })).toBe('sm'); }); - it('keeps the same corner for a user and an organization', () => { + it('rounds fully only around a user avatar on its own', () => { const corner = (props: Partial) => { const { unmount } = renderTrigger(props); const className = screen.getByRole('button', { name: /Open account menu/ }).className; @@ -899,6 +899,9 @@ describe('UserButtonTrigger', () => { }; expect(corner({ mode: 'organization' })).toEqual(corner({ mode: 'user' })); + expect(corner({ mode: 'organization', renderTriggerLabel: false })).not.toEqual( + corner({ mode: 'user', renderTriggerLabel: false }), + ); }); it('names the active organization before its membership list has loaded', () => { diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index 0a4e9246f88..c91a959ff50 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -29,6 +29,10 @@ export const styles = stylex.create({ gap: space['1.5'], }, + triggerRound: { + borderRadius: radiusVars['--cl-radius-full'], + }, + // Matches `Item.Label`, so the trigger names a workspace the same way its row does. Capped, // because the trigger sits in a host app's chrome and a long workspace name would push it apart. triggerName: { diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index 2bbcee603ba..ca5e890edd8 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1075,7 +1075,12 @@ export function UserButtonTrigger({ return ( Date: Mon, 14 Sep 2026 12:36:01 -0400 Subject: [PATCH 7/8] feat(ui): dim the avatar-only trigger on hover --- .../mosaic/user-button/user-button.styles.ts | 24 ++++++++++++++----- .../mosaic/user-button/user-button.view.tsx | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index c91a959ff50..7d81aa99f12 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -11,6 +11,15 @@ export const styles = stylex.create({ borderRadius: radiusVars['--cl-radius-md'], borderStyle: 'none', alignItems: 'center', + backgroundColor: 'transparent', + cursor: 'pointer', + display: 'inline-flex', + transitionDuration: durationVars['--cl-duration-base'], + }, + + triggerLabelled: { + padding: space['1'], + gap: space['1.5'], backgroundColor: { default: 'transparent', ':is([data-open])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, @@ -18,15 +27,18 @@ export const styles = stylex.create({ ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, }, }, - cursor: 'pointer', - display: 'inline-flex', - transitionDuration: durationVars['--cl-duration-base'], transitionProperty: 'background-color', }, - triggerLabelled: { - padding: space['1'], - gap: space['1.5'], + triggerAvatarOnly: { + opacity: { + default: 1, + ':is([data-open])': 0.8, + '@media (hover: hover)': { + ':hover': 0.8, + }, + }, + transitionProperty: 'opacity', }, triggerRound: { diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index ca5e890edd8..a51dff02ffc 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1078,7 +1078,7 @@ export function UserButtonTrigger({ xstyle={[ focusOutline.visible, styles.trigger, - renderTriggerLabel ? styles.triggerLabelled : null, + renderTriggerLabel ? styles.triggerLabelled : styles.triggerAvatarOnly, !renderTriggerLabel && shape === 'circle' ? styles.triggerRound : null, ]} > From 8c26401b7fd3e040befd8705beb77205d9b2c529 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 14 Sep 2026 13:38:22 -0400 Subject: [PATCH 8/8] fix(ui): pass the trigger caret atoms as xstyle --- packages/ui/src/mosaic/user-button/user-button.view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index a51dff02ffc..f028621995e 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1096,7 +1096,7 @@ export function UserButtonTrigger({ aria-hidden name='chevron-down' size='sm' - {...stylex.props(styles.triggerCaret)} + xstyle={styles.triggerCaret} /> ) : null}