From ce3c63b561a29eb68a651d25d8d1489ab9020249 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 09:29:42 -0400 Subject: [PATCH 1/6] 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 779142151f7..43705c9f68f 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -1,22 +1,27 @@ 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({ - // 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, @@ -29,6 +34,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 a2d34b53b1f..973257412dd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1099,6 +1099,12 @@ export function UserButtonTrigger({ <> {name} {planLabel ? {planLabel} : null} + ) : null} From c9971aebe1da19ec1caa77eac48e113fc9c331ee Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 09:35:44 -0400 Subject: [PATCH 2/6] 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 43705c9f68f..d148fbbb0a9 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -58,5 +58,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 f123a93ca9015ea450f5e67a3d981a00e6dd6c86 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 10:24:19 -0400 Subject: [PATCH 3/6] 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 d148fbbb0a9..43705c9f68f 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -58,5 +58,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 558b33622cb4a8a25befeabb66855f7eb355b5ee Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 13:34:55 -0400 Subject: [PATCH 4/6] 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 43705c9f68f..247ba55c97b 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -36,6 +36,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 627fa505ce588a7ac627c81b94f090430421c82a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 19:46:37 -0400 Subject: [PATCH 5/6] 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 247ba55c97b..3802a3f8fdc 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -4,7 +4,8 @@ import { colorVars, durationVars, fontWeightVars, radiusVars, space, typeScaleVa export const styles = stylex.create({ trigger: { - padding: space['1'], + padding: 0, + borderRadius: radiusVars['--cl-radius-md'], borderStyle: 'none', alignItems: 'center', backgroundColor: { @@ -21,6 +22,7 @@ export const styles = stylex.create({ }, triggerLabelled: { + padding: space['1'], gap: space['1.5'], }, @@ -54,10 +56,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 973257412dd..e89b16ced19 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -25,7 +25,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, @@ -1082,18 +1082,13 @@ export function UserButtonTrigger({ return ( {renderTriggerLabel ? ( <> From b4220d2a63407c3773d9d21a7fae3e924e776360 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 20:07:52 -0400 Subject: [PATCH 6/6] 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 3802a3f8fdc..904eb7526fd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -26,6 +26,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 e89b16ced19..369e348d902 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -1082,7 +1082,12 @@ export function UserButtonTrigger({ return (