From 966efc37417dba1b752bea0294e43bec15bd9d2a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 24 Sep 2026 13:20:46 -0600 Subject: [PATCH 1/2] feat(mosaic): flat user profile story, single-card active devices, password header Add a FlatContainedGroups UserProfile story that restyles every section group as a tinted containing box. Active devices now lists every device as a row in one surface with a ghost sign-out-all button beside the title, and Password renders as a surface header with its action on the value row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../user-profile-security-panel.view.test.tsx | 5 +- ...er-profile-active-devices-section.view.tsx | 73 ++++++---------- .../user-profile-active-devices.messages.ts | 3 - .../user-profile-password-row.view.tsx | 42 +++++---- .../user-profile-password-section.view.tsx | 5 +- .../user-profile-security-panel.styles.ts | 6 ++ .../swingset/src/stories/user-profile.mdx | 10 +++ .../src/stories/user-profile.stories.tsx | 87 +++++++++++++++++++ 8 files changed, 160 insertions(+), 71 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index c0c7b3ab75b..2eabf8a18dd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -70,7 +70,7 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); expect(screen.getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); - expect(screen.getByText('2 other devices')).toBeInTheDocument(); + expect(screen.getByText('Clerk App on macOS')).toBeInTheDocument(); expect( screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), ).toBeInTheDocument(); @@ -131,8 +131,7 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - const otherDevices = screen.getByRole('group', { name: 'Other devices' }); - await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); + await user.click(screen.getByRole('button', { name: 'Manage Safari on iOS' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 1be9f5b1369..70d1c50b48c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -73,9 +73,9 @@ export function UserProfileActiveDevicesSectionView({ } }; - // Confirming takes the whole card with it, trigger included — hence the dialog mounted outside - // it, and the current device as the place focus lands. Cancelling keeps the trigger, so focus - // goes back to it. + // Confirming takes the trigger with the other devices — hence the dialog mounted outside it, and + // the current device as the place focus lands. Cancelling keeps the trigger, so focus goes back + // to it. const focusAfterSignOutAll = () => { const signedOut = signedOutAll.current; signedOutAll.current = false; @@ -86,7 +86,20 @@ export function UserProfileActiveDevicesSectionView({
- {m.title} +
+ {m.title} + {onSignOutAllOtherDevices && otherDevices.length > 0 ? ( + + ) : null} +
{currentDevices.length > 0 ? ( currentDevices.map(device => ( @@ -107,50 +120,18 @@ export function UserProfileActiveDevicesSectionView({ )} + {otherDevices.map(device => ( + + deviceDetails.open(device)} + /> + + ))}
- {otherDevices.length > 0 ? ( - - - - - - {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { - count: String(otherDevices.length), - })} - - - {onSignOutAllOtherDevices ? ( - - - - ) : null} - - - {otherDevices.map(device => ( - deviceDetails.open(device)} - /> - ))} - - - - ) : null}
{onSignOutAllOtherDevices ? ( ) { const m = useMessages('userProfilePasswordSection'); return ( - - + <> + {m.label} - {hasPassword ? m.masked : m.noPasswordSet} - {managedBy ? ( - - - - ) : onSubmitPassword ? ( - - - - ) : null} - - + + + + + {hasPassword ? m.masked : m.noPasswordSet} + + {managedBy ? ( + + + + ) : onSubmitPassword ? ( + + + + ) : null} + + + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index f0c0099cc4a..eb411405887 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -25,7 +25,10 @@ export function UserProfilePasswordSectionView({ } const group = ( - + {title ? {title} : null} + +## Flat contained groups + +Every contained group restyled flat from external CSS, using the same selectors as the Section +`FlatContainedGroups` example. + + diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx index 04a8034c036..8ed0b330b12 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -53,3 +53,90 @@ export function Overlay() { ); } + +export function FlatContainedGroups() { + const { activePage, setActivePage, pages } = useUserProfileFixture(); + return ( + <> + + + + ); +} From a23eb35db92e037adf332ca3a7e342b236b6b26a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 24 Sep 2026 13:43:15 -0600 Subject: [PATCH 2/2] feat(mosaic): user profile section titles become surface headers Every user profile section group now carries its title as a Section.Header inside the surface instead of a Section.Title above it, and the security panel drops the Authentication title. Section.Header gets a minimum height so headers match with or without an action, the divider under a header is drawn by the Section.Items that follows it, and groups inside a section use the same space-8 gap as the sections themselves. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/user-profile-section-headers.md | 2 + .../src/components/profile/profile.styles.ts | 5 +- .../section/section.markers.stylex.ts | 1 + .../src/components/section/section.styles.ts | 16 +++-- .../mosaic/src/components/section/section.tsx | 4 +- .../user-profile-billing-panel.view.test.tsx | 2 +- .../user-profile-delete-section.view.test.tsx | 2 +- ...ser-profile-passkeys-section.view.test.tsx | 8 +-- ...ser-profile-password-section.view.test.tsx | 4 +- .../user-profile-profile-panel.view.test.tsx | 10 ++-- .../user-profile-security-panel.view.test.tsx | 17 +++--- .../user-profile-account-section.view.tsx | 12 +++- ...er-profile-active-devices-section.view.tsx | 34 ++++++----- ...rofile-connected-accounts-section.view.tsx | 8 ++- .../user-profile-delete-section.view.tsx | 8 ++- ...ofile-enterprise-accounts-section.view.tsx | 8 ++- .../user-profile-mfa-section.view.tsx | 3 - .../user-profile-passkeys-section.view.tsx | 5 +- .../user-profile-password-row.view.tsx | 2 +- .../user-profile-password-section.messages.ts | 1 - .../user-profile-password-section.types.ts | 1 - .../user-profile-password-section.view.tsx | 5 +- .../user-profile-security-list.tsx | 5 +- .../user-profile-security-panel.styles.ts | 6 -- .../user-profile-security-panel.view.tsx | 7 ++- ...user-profile-subscription-section.view.tsx | 8 ++- ...user-profile-web3-wallets-section.view.tsx | 8 ++- .../src/stories/fixtures/user-profile-mfa.ts | 1 - packages/swingset/src/stories/section.mdx | 2 +- .../swingset/src/stories/section.stories.tsx | 2 + .../stories/user-profile-passkeys-section.mdx | 15 +++-- .../user-profile-passkeys-section.stories.tsx | 8 +-- .../swingset/src/stories/user-profile.mdx | 2 +- .../src/stories/user-profile.stories.tsx | 58 +++++++++---------- 34 files changed, 147 insertions(+), 133 deletions(-) create mode 100644 .changeset/user-profile-section-headers.md diff --git a/.changeset/user-profile-section-headers.md b/.changeset/user-profile-section-headers.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-section-headers.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index c525ced3a8d..000b1168e6f 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -373,8 +373,11 @@ export const panelStyles = stylex.create({ flexDirection: 'column', }, sections: { - gap: space['10'], + gap: space['8'], display: 'flex', flexDirection: 'column', }, + groups: { + rowGap: space['8'], + }, }); diff --git a/packages/mosaic/src/components/section/section.markers.stylex.ts b/packages/mosaic/src/components/section/section.markers.stylex.ts index c2598c508aa..cd5ab064f7a 100644 --- a/packages/mosaic/src/components/section/section.markers.stylex.ts +++ b/packages/mosaic/src/components/section/section.markers.stylex.ts @@ -1,3 +1,4 @@ import * as stylex from '@stylexjs/stylex'; export const sectionNestedItemMarker = stylex.defineMarker(); +export const sectionHeaderMarker = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 9cc55d4fa0b..01a348d99c4 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, sectionVars, space, typeScaleVars } from '../../tokens.stylex'; -import { sectionNestedItemMarker } from './section.markers.stylex'; +import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; export const styles = stylex.create({ root: { @@ -46,16 +46,20 @@ export const styles = stylex.create({ header: { marginInline: space['4'], paddingBlock: space['3'], - borderBlockEndColor: colorVars['--cl-color-border'], - borderBlockEndStyle: 'solid', - borderBlockEndWidth: '1px', + minHeight: space['13'], width: 'auto', }, items: { - paddingInline: space['4'], + marginInline: space['4'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '0px', + [stylex.when.siblingBefore(':where(*)', sectionHeaderMarker)]: '1px', + }, display: 'flex', flexDirection: 'column', - width: '100%', + width: 'auto', }, item: { alignItems: 'center', diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 94291af3c4f..d333fd45a1a 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -12,7 +12,7 @@ import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import type { HeadingProps } from '../heading'; import { Heading } from '../heading'; -import { sectionNestedItemMarker } from './section.markers.stylex'; +import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; @@ -134,7 +134,7 @@ const Header = React.forwardRef(function Sec props: { ...mergeStyleProps( themeProps('section-header'), - stylex.props(reset.base, styles.item, styles.header, xstyle), + stylex.props(reset.base, styles.item, styles.header, sectionHeaderMarker, xstyle), rest, ), }, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx index 01baad9dd5a..77919da223e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx @@ -54,7 +54,7 @@ describe('UserProfileBillingPanelView', () => { renderView(); expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 4, name: 'Subscription' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Subscription' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Payment methods' })).toBeInTheDocument(); expect(screen.getByText('Basic Plan')).toBeInTheDocument(); expect(screen.getByText('$12.00')).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx index 9f70ce5dea6..218ae200b53 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx @@ -22,7 +22,7 @@ describe('UserProfileDeleteSectionView', () => { it('renders the danger zone with the dialog closed', () => { renderView(); - expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument(); expect( screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), ).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx index fd65d4206b1..a69c9c063b7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -29,9 +29,9 @@ function renderView(overrides: Partial = {} describe('passkeys section', () => { it('shows existing passkeys without actions when their callbacks are unavailable', () => { - renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined, sectionTitle: 'Authentication' }); + renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined }); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('Passkeys')).toBeVisible(); expect(screen.getByText('MacBook')).toBeVisible(); expect(screen.getByText('iPhone')).toBeVisible(); @@ -120,9 +120,9 @@ describe('passkeys section', () => { }); it('keeps an empty section visible when Add is unavailable', () => { - renderView({ passkeys: [], onAdd: undefined, sectionTitle: 'Authentication' }); + renderView({ passkeys: [], onAdd: undefined }); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('Passkeys')).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 21a49b497e9..eda84512687 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -43,7 +43,7 @@ describe('UserProfilePasswordSectionView', () => { const user = userEvent.setup(); renderView({ hasPassword: false, onSubmitPassword }); - expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Password' })).toBeInTheDocument(); expect(screen.getByText('Password')).toBeVisible(); expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); expect(screen.getByText('No password set')).toBeVisible(); @@ -109,7 +109,7 @@ describe('UserProfilePasswordSectionView', () => { , ); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Password' })).toBeVisible(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index d6037709190..6e39db3223c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -61,7 +61,7 @@ describe('UserProfileProfilePanelView', () => { /> , ); - expect(screen.queryByRole('heading', { name: 'Connected accounts' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Connected accounts' })).not.toBeInTheDocument(); expect(screen.getByRole('alertdialog', { name: 'Remove connected account' })).toBeInTheDocument(); await act(async () => { @@ -94,7 +94,7 @@ describe('UserProfileProfilePanelView', () => { /> , ); - expect(screen.queryByRole('heading', { name: 'Web3 wallets' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Web3 wallets' })).not.toBeInTheDocument(); expect(screen.getByRole('alertdialog')).toHaveTextContent('0x1234'); await act(async () => { removal.resolve(); @@ -293,9 +293,9 @@ describe('UserProfileProfilePanelView', () => { onDeleteAccount, }); - expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Connected accounts' })).toBeInTheDocument(); expect(screen.getByText('Google')).toBeVisible(); - expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', @@ -349,7 +349,7 @@ describe('UserProfileProfilePanelView', () => { onRemoveWeb3Wallet: vi.fn(), }); - expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 2eabf8a18dd..79ce0161d7a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -61,9 +61,9 @@ describe('UserProfileSecurityPanelView', () => { renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 4, name: 'Active devices' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Active devices' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Password')).toBeVisible(); expect(screen.getByText('Passkeys')).toBeVisible(); expect(screen.getByText('2-step verification')).toBeVisible(); @@ -178,7 +178,7 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument(); }); - it('keeps the authentication heading on MFA when existing passkeys are hidden', () => { + it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ hasPassword: false, passkeysVisible: false, @@ -190,17 +190,16 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); expect(screen.queryByText('Passkey')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); expect(screen.getByText('2-step verification')).toBeVisible(); }); - it('keeps one authentication heading when passkeys are empty and Add is unavailable', () => { + it('keeps one authentication section when passkeys are empty and Add is unavailable', () => { renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); expect(within(section).getByText('Passkeys')).toBeVisible(); expect(within(section).getByText('No passkeys added')).toBeVisible(); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); expect(screen.getByRole('group', { name: '2-step verification' })).toBeVisible(); }); @@ -225,7 +224,7 @@ describe('UserProfileSecurityPanelView', () => { /> , ); - expect(screen.getByText('Authentication')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument(); expect(screen.getByRole('alertdialog', { name: 'Remove passkey' })).toBeVisible(); await act(async () => { @@ -233,7 +232,7 @@ describe('UserProfileSecurityPanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); expect(screen.getByText('Passkeys')).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 92ef04d1168..abdd8dedcbe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,3 +1,4 @@ +import { panelStyles } from '../../../components/profile'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; @@ -112,10 +113,17 @@ export function UserProfileAccountSectionView({ ); return ( - + - {m.sectionTitle} + + + {m.sectionTitle} + + - + -
- {m.title} - {onSignOutAllOtherDevices && otherDevices.length > 0 ? ( - - ) : null} -
+ + + {m.title} + + {onSignOutAllOtherDevices && otherDevices.length > 0 ? ( + + + + ) : null} + {currentDevices.length > 0 ? ( currentDevices.map(device => ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx index 1a72d8bec73..153113a354f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx @@ -42,10 +42,14 @@ export function UserProfileConnectedAccountsSectionView({ return ( <> {hasRows ? ( - + - {m.title} + + + {m.title} + + {accounts.map(account => ( + - {m.sectionTitle} + + + {m.sectionTitle} + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx index ce2cec3856d..f729aa174ae 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -24,10 +24,14 @@ export function UserProfileEnterpriseAccountsSectionView({ return null; } return ( - + - {m.title} + + + {m.title} + + {accounts.map(account => ( ; addControl?: ReactNode; - sectionTitle?: string; /** Renders only the group, for composing inside a parent `Section.Root`. */ asGroup?: boolean; onAdd?: (type: UserProfileMfaAddableMethod) => void; @@ -39,7 +38,6 @@ export function UserProfileMfaSectionView({ addableMethods, addButtonRef, addControl, - sectionTitle, asGroup, onAdd, onRegenerateBackupCodes, @@ -102,7 +100,6 @@ export function UserProfileMfaSectionView({ emptyLabel={m.empty} hasItems={methods.length > 0} label={m.label} - sectionTitle={sectionTitle} asGroup={asGroup} > {methods.map(method => ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 2f6c5ed71f4..7e7c5a51f38 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -20,7 +20,6 @@ export interface UserProfilePasskey { export interface UserProfilePasskeysSectionViewProps { passkeys: UserProfilePasskey[]; - sectionTitle?: string; /** Renders only the group, for composing inside a parent `Section.Root`. */ asGroup?: boolean; onAdd?: () => void; @@ -31,7 +30,6 @@ export interface UserProfilePasskeysSectionViewProps { export function UserProfilePasskeysSectionView({ passkeys, - sectionTitle, asGroup = false, onAdd, addError, @@ -54,7 +52,6 @@ export function UserProfilePasskeysSectionView({ variant={asGroup ? 'contained' : 'default'} aria-label={asGroup ? m.label : undefined} > - {sectionTitle ? {sectionTitle} : null} @@ -119,7 +116,7 @@ export function UserProfilePasskeysSectionView({ {group} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 7c2b0a13e6f..0814c07fe0a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -19,7 +19,7 @@ export function UserProfilePasswordRowView({ requiresCurrentPassword = false, managedBy, onSubmitPassword, -}: Omit) { +}: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); return ( <> diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 1ad7b77b90b..f6550798bbf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -1,5 +1,4 @@ export const userProfilePasswordSectionMessages = { - sectionTitle: 'Authentication', label: 'Password', masked: '••••••••••••••••••', noPasswordSet: 'No password set', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 0a116af5895..220d1fdf280 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -12,7 +12,6 @@ export interface UserProfilePasswordManagedBy { } export interface UserProfilePasswordSectionViewProps { - sectionTitle?: string; /** Renders only the group, for composing inside a parent `Section.Root`. */ asGroup?: boolean; hasPassword?: boolean; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index eb411405887..269e1cd4849 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -11,7 +11,6 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ - sectionTitle, asGroup = false, hasPassword = false, requiresCurrentPassword = false, @@ -19,7 +18,6 @@ export function UserProfilePasswordSectionView({ onSubmitPassword, }: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); - const title = sectionTitle ?? m.sectionTitle; if (!hasPassword && !managedBy && !onSubmitPassword) { return null; } @@ -29,7 +27,6 @@ export function UserProfilePasswordSectionView({ variant={asGroup ? 'contained' : 'default'} aria-label={asGroup ? m.label : undefined} > - {title ? {title} : null} ); - return asGroup ? group : {group}; + return asGroup ? group : {group}; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx index 1f6f3431dfc..0bdacd11bf6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx @@ -5,7 +5,6 @@ import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; export function UserProfileSecurityList({ - sectionTitle, asGroup = false, label, addLabel, @@ -15,7 +14,6 @@ export function UserProfileSecurityList({ addControl, children, }: { - sectionTitle?: string; asGroup?: boolean; label: string; addLabel: string; @@ -30,7 +28,6 @@ export function UserProfileSecurityList({ variant={asGroup ? 'contained' : 'default'} aria-label={asGroup ? label : undefined} > - {sectionTitle ? {sectionTitle} : null} @@ -72,5 +69,5 @@ export function UserProfileSecurityList({ ); - return asGroup ? group : {group}; + return asGroup ? group : {group}; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts index 4df375a3176..ab7f24daae3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts @@ -22,12 +22,6 @@ export const styles = stylex.create({ borderWidth: '1px', backgroundColor: colorVars['--cl-color-background-subtle'], }, - titleRow: { - alignItems: 'center', - columnGap: space['3'], - display: 'flex', - justifyContent: 'space-between', - }, sectionCards: { gap: space['3'], display: 'flex', diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 3f0ddd38bfc..84a9ae285cc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -87,7 +87,10 @@ export function UserProfileSecurityPanelView({ Security
{hasAuthentication ? ( - + {showPassword ? ( + - Subscription + + + Subscription + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index 90b354ed64d..19201c76642 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -46,10 +46,14 @@ export function UserProfileWeb3WalletsSectionView({ return ( <> {hasRows ? ( - + - {m.title} + + + {m.title} + + {wallets.map(wallet => ( void generateBackupCodes('regenerate') : undefined, diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index f756da6fce6..839f0fae655 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -28,7 +28,7 @@ import { Section } from '@clerk/mosaic/components/section'; `Section.Group` pairs an optional `Section.Title` with a `Section.Surface`. The surface is the outlined card. Its direct `Section.Row` children own separation and may stack one or more `Section.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, and `xl` sizes. -For a nested list, place a `Section.Header` and a `Section.Items` directly inside the surface. The header draws the divider above the list, and nested items are separated by dividers of their own. +For a nested list, place a `Section.Header` and a `Section.Items` directly inside the surface. The list draws the divider below the header, and nested items are separated by dividers of their own. A `Section.Header` can also head plain `Section.Row` children, which draw their own dividers. A section can hold more than one `Section.Group`. Set `variant='contained'` on a group that holds a nested list, such as a user's email addresses inside the account section. A contained group renders with `role='group'` and `data-variant='contained'`, so it can take its own accessible name and be styled apart from the section's main group. diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 69609e503f4..105246ba6d3 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -264,6 +264,8 @@ export function FlatContainedGroups() { background: none !important; } .cl-section-items { + margin-inline: 0 !important; + padding-inline: calc(var(--cl-spacing) * 4) !important; border: 1px solid var(--cl-color-border) !important; border-radius: var(--cl-radius-xl) !important; background: var(--cl-color-background) !important; diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index b36689c2f9c..40f74188b38 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -14,14 +14,13 @@ import * as Stories from './user-profile-passkeys-section.stories'; ## Props -| Prop | Type | Default | Description | -| -------------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | -| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | -| `sectionTitle` | `string` | — | Optional surrounding section heading. | -| `onAdd` | `() => void` | — | Shows Add when supplied. | -| `addError` | `string` | — | Inline creation error. | -| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | -| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | +| Prop | Type | Default | Description | +| ---------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | +| `onAdd` | `() => void` | — | Shows Add when supplied. | +| `addError` | `string` | — | Inline creation error. | +| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | +| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | ## Usage diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx index f57448d1b14..9ea16aa95c0 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -18,12 +18,7 @@ export const meta: StoryMeta = { function PasskeysExample({ empty = false, failOnce = false }: { empty?: boolean; failOnce?: boolean }): ReactElement { const fixture = usePasskeysFixture({ empty, failOnce }); - return ( - - ); + return ; } export function Default(): ReactElement { @@ -39,7 +34,6 @@ export function CreationUnavailable(): ReactElement { return ( ); diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index beae8a55ce7..de796755de9 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -31,7 +31,7 @@ and animates the popup; the profile paints it, names it, and carries the dismiss ## Flat contained groups -Every contained group restyled flat from external CSS, using the same selectors as the Section +Every section surface restyled flat from external CSS, using the same selectors as the Section `FlatContainedGroups` example. .cl-section-group:first-child > .cl-section-title { - position: absolute !important; - width: 1px !important; - height: 1px !important; - margin: -1px !important; - padding: 0 !important; - overflow: hidden !important; - clip-path: inset(50%) !important; - white-space: nowrap !important; - border: 0 !important; - } .cl-section, :has(> .cl-section, > div > .cl-section):not([class*='-panel']) { gap: var(--section-stack-gap) !important; } - .cl-section-group:not([data-variant='contained']), - .cl-section-group[data-variant='contained'] > .cl-section-surface { - row-gap: 0 !important; + .cl-section-surface { padding: 0 2px 2px !important; border: 1px solid var(--cl-color-border-subtle) !important; border-radius: calc(var(--cl-radius-xl) + 3px) !important; background: var(--cl-color-background-subtle) !important; } - .cl-section-group > .cl-section-title, - .cl-section-group > :has(> .cl-section-title), - .cl-section-header { - margin: 0 !important; - padding: 0.625rem calc(1rem + 3px) !important; - border: none !important; - } - .cl-section-group:has(.cl-section-actions > .cl-button[data-color='negative']) { + .cl-section-surface:has(.cl-section-actions > .cl-button[data-color='negative']) { border-color: transparent !important; background: var(--cl-color-negative-subtle) !important; } - .cl-section-group:has(.cl-section-actions > .cl-button[data-color='negative']) > .cl-section-title { + .cl-section-surface:has(.cl-section-actions > .cl-button[data-color='negative']) > .cl-section-header .cl-section-label { color: var(--cl-color-negative) !important; } + .cl-section-header { + margin: 0 !important; + padding: 0.625rem calc(1rem + 3px) !important; + border: none !important; + } .cl-section-header .cl-section-label { font-family: var(--cl-font-family-sans) !important; font-size: var(--cl-text-base-size) !important; @@ -112,23 +97,36 @@ export function FlatContainedGroups() { background: none !important; } .cl-section-items { + margin-inline: 0 !important; + padding-inline: 0 !important; border: 1px solid var(--cl-color-border) !important; border-radius: var(--cl-radius-xl) !important; background: var(--cl-color-background) !important; } - .cl-section-group:not([data-variant='contained']) > .cl-section-surface > .cl-section-row { - margin-inline: 0 !important; - padding-inline: calc(var(--cl-spacing) * 4) !important; - } - .cl-section-items { - padding-inline: 0 !important; - } .cl-section-items > .cl-section-item { padding-inline: calc(var(--cl-spacing) * 4) !important; } .cl-section-items .cl-section-item { min-height: var(--cl-section-row-min-height) !important; } + .cl-section-surface > .cl-section-row { + margin-inline: 0 !important; + padding-inline: calc(var(--cl-spacing) * 4) !important; + border-style: solid !important; + border-color: var(--cl-color-border) !important; + border-width: 1px 1px 0 !important; + background: var(--cl-color-background) !important; + } + .cl-section-surface > .cl-section-row:first-child, + .cl-section-surface > .cl-section-header + .cl-section-row { + border-start-start-radius: var(--cl-radius-xl) !important; + border-start-end-radius: var(--cl-radius-xl) !important; + } + .cl-section-surface > .cl-section-row:last-child { + border-bottom-width: 1px !important; + border-end-start-radius: var(--cl-radius-xl) !important; + border-end-end-radius: var(--cl-radius-xl) !important; + } } `}