From 2753589b4e71a49621e79f8934ac3d375cc021fb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 16 Sep 2026 19:02:11 -0400 Subject: [PATCH 1/8] feat(ui): add typed localization to MosaicProvider --- .changeset/mosaic-provider-localization.md | 2 + packages/mosaic/src/MosaicProvider.tsx | 15 +- .../src/__tests__/MosaicProvider.test.tsx | 22 ++ .../src/__tests__/localization.test.tsx | 98 +++++++ .../reverification/reverification.messages.ts | 2 +- .../reverification/reverification.view.tsx | 24 +- .../user-button/user-button.messages.ts | 7 +- .../features/user-button/user-button.view.tsx | 39 ++- .../user-profile-account-section.messages.ts | 7 +- .../user-profile-account-section.view.tsx | 3 +- .../user-profile-add-email.controller.ts | 40 ++- .../user-profile-add-email.dialog.tsx | 5 +- .../user-profile-add-email.messages.ts | 2 +- .../user-profile-add-phone.controller.ts | 40 ++- .../user-profile-add-phone.dialog.tsx | 5 +- .../user-profile-add-phone.messages.ts | 2 +- .../user-profile-contact-list-row.view.tsx | 4 +- .../user-profile-contact-row.view.tsx | 3 +- .../user-profile-edit-name.dialog.tsx | 3 +- .../user-profile-edit-username.dialog.tsx | 3 +- .../user-profile-email-row.view.tsx | 5 +- .../user-profile-name-row.view.tsx | 4 +- .../user-profile-phone-row.view.tsx | 5 +- .../user-profile-picture-row.view.tsx | 4 +- .../user-profile-username-row.view.tsx | 4 +- .../user-profile-verify-email-link.dialog.tsx | 4 +- ...user-profile-verify-email-link.messages.ts | 2 +- .../user-profile-verify-email-sso.dialog.tsx | 4 +- .../user-profile-verify-email-sso.messages.ts | 2 +- ...ser-profile-connected-account-row.view.tsx | 4 +- ...rofile-connected-accounts-section.view.tsx | 4 +- ...ser-profile-connected-accounts.messages.ts | 2 +- .../user-profile-delete-section.messages.ts | 7 +- .../user-profile-delete-section.view.tsx | 4 +- ...er-profile-enterprise-account-row.view.tsx | 4 +- ...le-enterprise-accounts-section.messages.ts | 2 +- ...ofile-enterprise-accounts-section.view.tsx | 3 +- ...r-profile-edit-password.controller.test.ts | 64 +++-- .../user-profile-edit-password.controller.ts | 19 +- .../user-profile-edit-password.dialog.tsx | 4 +- .../user-profile-password-row.view.tsx | 6 +- .../user-profile-password-section.messages.ts | 2 +- .../user-profile-password-section.view.tsx | 10 +- .../user-profile-web3-wallet-row.view.tsx | 4 +- ...user-profile-web3-wallets-section.view.tsx | 16 +- .../user-profile-web3-wallets.messages.ts | 2 +- .../user-profile/user-profile.messages.ts | 7 +- .../user-profile/user-profile.view.tsx | 7 +- packages/mosaic/src/localization/catalog.ts | 38 +++ packages/mosaic/src/localization/context.tsx | 53 ++++ packages/mosaic/src/localization/index.ts | 6 + .../src/localization/localization.test-d.ts | 83 ++++++ .../src/localization/messages.test-d.ts | 65 +++++ .../{utils => localization}/messages.test.tsx | 0 .../src/{utils => localization}/messages.ts | 50 +++- packages/mosaic/src/localization/registry.ts | 31 +++ packages/swingset/CLAUDE.md | 3 +- .../swingset/src/components/DocsViewer.tsx | 3 + .../swingset/src/components/app-sidebar.tsx | 8 +- packages/swingset/src/lib/registry.ts | 21 ++ .../swingset/src/stories/localization.mdx | 245 ++++++++++++++++++ .../src/stories/localization.stories.tsx | 217 ++++++++++++++++ references/mosaic-architecture.md | 38 ++- 63 files changed, 1187 insertions(+), 205 deletions(-) create mode 100644 .changeset/mosaic-provider-localization.md create mode 100644 packages/mosaic/src/__tests__/localization.test.tsx create mode 100644 packages/mosaic/src/localization/catalog.ts create mode 100644 packages/mosaic/src/localization/context.tsx create mode 100644 packages/mosaic/src/localization/index.ts create mode 100644 packages/mosaic/src/localization/localization.test-d.ts create mode 100644 packages/mosaic/src/localization/messages.test-d.ts rename packages/mosaic/src/{utils => localization}/messages.test.tsx (100%) rename packages/mosaic/src/{utils => localization}/messages.ts (61%) create mode 100644 packages/mosaic/src/localization/registry.ts create mode 100644 packages/swingset/src/stories/localization.mdx create mode 100644 packages/swingset/src/stories/localization.stories.tsx diff --git a/.changeset/mosaic-provider-localization.md b/.changeset/mosaic-provider-localization.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-provider-localization.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/MosaicProvider.tsx b/packages/mosaic/src/MosaicProvider.tsx index 60e2b464d01..1099b7c6b29 100644 --- a/packages/mosaic/src/MosaicProvider.tsx +++ b/packages/mosaic/src/MosaicProvider.tsx @@ -2,18 +2,27 @@ import React from 'react'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; +import type { MosaicLocalization } from './localization'; +import { MosaicLocalizationProvider, resolveLocalization } from './localization'; export interface MosaicProviderProps { children: React.ReactNode; /** Per-name icon glyph overrides, applied to every `` below this provider. */ icons?: MosaicIconOverrides; + /** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */ + localization?: MosaicLocalization; } // Exported Mosaic components annotate a React return type on purpose. The package sets // `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the // published `.d.ts`, which React 19 consumers reject as a JSX element type. -export function MosaicProvider({ children, icons }: MosaicProviderProps): React.ReactElement { - const value = React.useMemo(() => icons ?? {}, [icons]); +export function MosaicProvider({ children, icons, localization }: MosaicProviderProps): React.ReactElement { + const iconsValue = React.useMemo(() => icons ?? {}, [icons]); + const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); - return {children}; + return ( + + {children} + + ); } diff --git a/packages/mosaic/src/__tests__/MosaicProvider.test.tsx b/packages/mosaic/src/__tests__/MosaicProvider.test.tsx index 3e0f17825b0..d5d4eeec4e0 100644 --- a/packages/mosaic/src/__tests__/MosaicProvider.test.tsx +++ b/packages/mosaic/src/__tests__/MosaicProvider.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { MosaicIconOverrides } from '../icons/overrides'; import { useMosaicIcons } from '../icons/overrides'; +import { useLocale, useMessages } from '../localization'; import { MosaicProvider } from '../MosaicProvider'; describe('MosaicProvider icons', () => { @@ -27,3 +28,24 @@ describe('MosaicProvider icons', () => { expect(result.current).toEqual({}); }); }); + +describe('MosaicProvider localization', () => { + it('overlays the localization prop onto the built-in messages', () => { + const { result } = renderHook(() => useMessages('userButton'), { + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { localization: { messages: { 'userButton.popup.label': 'Konto' } } }, + children, + ), + }); + expect(result.current.popup.label).toBe('Konto'); + }); + + it('exposes the locale via useLocale', () => { + const { result } = renderHook(() => useLocale(), { + wrapper: ({ children }) => React.createElement(MosaicProvider, { localization: { locale: 'de' } }, children), + }); + expect(result.current).toBe('de'); + }); +}); diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx new file mode 100644 index 00000000000..28a81b58e47 --- /dev/null +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { mosaicMessages, resolveLocalization, useLocale, useMessages } from '../localization'; +import { MosaicProvider } from '../MosaicProvider'; + +describe('resolveLocalization', () => { + it('returns the base messages untouched when nothing is overridden', () => { + expect(resolveLocalization(undefined).messages).toBe(mosaicMessages); + expect(resolveLocalization({}).messages).toBe(mosaicMessages); + expect(resolveLocalization({ messages: {} }).messages).toEqual(mosaicMessages); + }); + + it('overlays nested overrides and keeps sibling keys', () => { + const { messages } = resolveLocalization({ messages: { userButton: { trigger: { open: 'Menü für {name}' } } } }); + expect(messages.userButton.trigger.open).toBe('Menü für {name}'); + expect(messages.userButton.popup.label).toBe(mosaicMessages.userButton.popup.label); + expect(messages.userProfile).toBe(mosaicMessages.userProfile); + }); + + it('expands dot paths', () => { + const { messages } = resolveLocalization({ messages: { 'userButton.popup.label': 'Konto' } }); + expect(messages.userButton.popup.label).toBe('Konto'); + expect(messages.userButton.workspaces.personal).toBe(mosaicMessages.userButton.workspaces.personal); + }); + + it('merges plural forms per category', () => { + const { messages } = resolveLocalization({ + messages: { 'userButton.workspaces.members': { few: '{count} Mitglieder' } }, + }); + expect(messages.userButton.workspaces.members).toEqual({ + one: '{count} member', + few: '{count} Mitglieder', + other: '{count} members', + }); + }); + + it('lets later keys win over earlier ones when nested and dot forms overlap', () => { + const { messages } = resolveLocalization({ + messages: { + userButton: { popup: { label: 'nested' } }, + 'userButton.popup.label': 'flat', + }, + }); + expect(messages.userButton.popup.label).toBe('flat'); + }); + + it('applies overrides on top of the messages', () => { + const esES = { 'userButton.popup.label': 'Cuenta', 'userButton.trigger.open': 'Abrir menú de {name}' }; + const { messages } = resolveLocalization({ messages: esES, overrides: { 'userButton.popup.label': 'Mi cuenta' } }); + expect(messages.userButton.popup.label).toBe('Mi cuenta'); + expect(messages.userButton.trigger.open).toBe('Abrir menú de {name}'); + }); + + it('defaults the locale to en and passes a given one through', () => { + expect(resolveLocalization(undefined).locale).toBe('en'); + expect(resolveLocalization({ locale: 'es-ES' }).locale).toBe('es-ES'); + }); + + it('does not mutate the base messages', () => { + resolveLocalization({ messages: { 'userButton.popup.label': 'Konto' } }); + expect(mosaicMessages.userButton.popup.label).toBe('Account'); + }); +}); + +describe('useMessages', () => { + it('returns the base namespace without a provider', () => { + const { result } = renderHook(() => useMessages('userButton')); + expect(result.current).toBe(mosaicMessages.userButton); + }); + + it('returns the localized namespace under a provider', () => { + const { result } = renderHook(() => useMessages('userButton'), { + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { localization: { messages: { 'userButton.popup.label': 'Konto' } } }, + children, + ), + }); + expect(result.current.popup.label).toBe('Konto'); + expect(result.current.trigger.open).toBe(mosaicMessages.userButton.trigger.open); + }); +}); + +describe('useLocale', () => { + it('defaults to en', () => { + expect(renderHook(() => useLocale()).result.current).toBe('en'); + }); + + it('reads the provider locale', () => { + const { result } = renderHook(() => useLocale(), { + wrapper: ({ children }) => React.createElement(MosaicProvider, { localization: { locale: 'de' } }, children), + }); + expect(result.current).toBe('de'); + }); +}); diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index 5765e02dfc1..2b26f27a698 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -68,4 +68,4 @@ export const reverificationMessages = { unstable__errors__generic: 'Something went wrong. Please try again.', /** Announced while an attempt is in flight; the button's own label stays visible. */ verifying: 'Verifying', -}; +} as const; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index 8f85685ab71..49850e3e972 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -1,8 +1,8 @@ import { Card } from '../../components/card'; import { Flow } from '../../components/flow'; import type { IconName } from '../../icons/registry'; -import { fill } from '../../utils/messages'; -import { reverificationMessages as m } from './reverification.messages'; +import type { MosaicMessages } from '../../localization'; +import { fill, useMessages } from '../../localization'; import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; import { ReverificationBackupCode } from './steps/reverification-backup-code'; import { ReverificationHelp } from './steps/reverification-help'; @@ -11,11 +11,7 @@ import { ReverificationOTP } from './steps/reverification-otp'; import { ReverificationPasskey } from './steps/reverification-passkey'; import { ReverificationPassword } from './steps/reverification-password'; -const actions = { - secondaryActionLabel: m.footerActionLink__useAnotherMethod, - primaryActionLabel: m.formButtonPrimary, - pendingLabel: m.verifying, -}; +type Messages = MosaicMessages['reverification']; const methodIcon = { password: 'security-lock-square', @@ -26,7 +22,7 @@ const methodIcon = { backup_code: 'security-phone', } as const satisfies Record; -function methodLabel(method: ReverificationMethod): string { +function methodLabel(method: ReverificationMethod, m: Messages): string { const identifier = 'identifier' in method ? method.identifier : ''; switch (method.strategy) { case 'password': @@ -44,7 +40,7 @@ function methodLabel(method: ReverificationMethod): string { } } -function otpCopy(channel: ReverificationOtpChannel | undefined) { +function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) { if (channel === 'email') { return m.emailCode; } @@ -55,6 +51,7 @@ function otpCopy(channel: ReverificationOtpChannel | undefined) { } export function ReverificationView(props: ReverificationViewProps): JSX.Element { + const m = useMessages('reverification'); const { step, direction, @@ -76,7 +73,12 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element resendRemainingSeconds, } = props; - const otp = otpCopy(otpChannel); + const otp = otpCopy(otpChannel, m); + const actions = { + secondaryActionLabel: m.footerActionLink__useAnotherMethod, + primaryActionLabel: m.formButtonPrimary, + pendingLabel: m.verifying, + }; const hasAlternatives = methods.length > 0; const resendLabel = otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton; const resend = @@ -179,7 +181,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element }} methods={methods.map(method => ({ id: method.id, - label: methodLabel(method), + label: methodLabel(method, m), icon: methodIcon[method.strategy], }))} pendingMethodId={pendingMethodId} diff --git a/packages/mosaic/src/features/user-button/user-button.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts index 609e4fcb1c9..52d8e876d56 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -1,8 +1,3 @@ -/** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userButton', userButtonMessages)`, not of hunting the literals down first. - */ export const userButtonMessages = { trigger: { open: 'Open account menu for {name}', @@ -34,4 +29,4 @@ export const userButtonMessages = { organization: 'Manage organization', createOrganization: 'Create organization', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-button/user-button.view.tsx b/packages/mosaic/src/features/user-button/user-button.view.tsx index d3e8594244c..3b27b416d56 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -18,14 +18,14 @@ import { Popover } from '../../components/popover'; import { scrollAreaViewport } from '../../components/scroll-area'; import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; +import type { MosaicMessages } from '../../localization'; +import { fill, plural, useLocale, useMessages } from '../../localization'; import { applyOrder } from '../../utils/apply-order'; import { focusOutline } from '../../utils/focus-outline.styles'; -import { fill, plural } from '../../utils/messages'; import { rtl } from '../../utils/rtl.styles'; import { truncationStyles } from '../../utils/typography.styles'; import type { UserButtonLayout } from './user-button.layout'; import { resolveUserButtonLayout } from './user-button.layout'; -import { userButtonMessages as m } from './user-button.messages'; import { styles } from './user-button.styles'; import type { UserButtonBrandingProps, @@ -102,12 +102,12 @@ type ActiveWorkspace = * What the surface leads with: named in the trigger and headed in the popup, so the two always * agree. An organization-led surface with no org and no personal workspace is no selection. */ -function leadWorkspace({ - layout, - activeOrganization, - activeSession, - hidePersonal, -}: UserButtonContextValue): ActiveWorkspace { +type Messages = MosaicMessages['userButton']; + +function leadWorkspace( + { layout, activeOrganization, activeSession, hidePersonal }: UserButtonContextValue, + m: Messages, +): ActiveWorkspace { if (layout.leadWith === 'organization') { if (activeOrganization) { return { @@ -125,10 +125,10 @@ function leadWorkspace({ return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; } -function membershipSubtitle(membership: UserButtonMembership): string { +function membershipSubtitle(membership: UserButtonMembership, m: Messages, locale: string): string { const parts: string[] = []; if (membership.membersCount !== undefined) { - parts.push(plural(m.workspaces.members, membership.membersCount)); + parts.push(plural(m.workspaces.members, membership.membersCount, locale)); } if (membership.planLabel) { parts.push(membership.planLabel); @@ -226,6 +226,7 @@ function SwitcherRow({ busy, disabled, }: SwitcherRowProps) { + const m = useMessages('userButton'); // Selecting what is already selected does nothing, so the active row is not a button at all. A // row that is merely waiting stays one, disabled. const select = active ? undefined : onSelect; @@ -368,10 +369,12 @@ function HeaderActionButton({ label, icon, onClick, busyKey }: HeaderAction) { /** The active workspace: who you are signed in as, and what you can do about it. */ function Header() { + const m = useMessages('userButton'); + const locale = useLocale(); const data = useUserButtonContext(); const signOutSession = data.onSignOutSession; const { sessionId, identifier } = data.activeSession; - const workspace = leadWorkspace(data); + const workspace = leadWorkspace(data, m); const { name, imageUrl, shape } = workspace; const organization = workspace.kind === 'organization' ? workspace.organization : undefined; // An account with no name is titled by its identifier, and repeating it underneath says nothing. @@ -379,7 +382,7 @@ function Header() { const accountSubtitle = identifier === name ? '' : identifier; const subtitle = workspace.kind === 'organization' - ? membershipSubtitle(workspace.organization) + ? membershipSubtitle(workspace.organization, m, locale) : workspace.kind === 'user' ? accountSubtitle : ''; @@ -479,6 +482,7 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA * below carries the ones that act on every account. */ function OrganizationsHeading() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const signOutSession = data.onSignOutSession; const { identifier, sessionId } = data.activeSession; @@ -554,6 +558,7 @@ function MembershipRow({ membership, active, onSelect }: MembershipRowProps) { * what they are about. */ function PersonalRow() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const selectOrganization = data.onSelectOrganization; const { busy, disabled } = useBusy(userButtonBusyKeys.selectOrganization(null)); @@ -606,6 +611,7 @@ interface PendingRowProps { /** A workspace on offer: joined from its own trailing button rather than by clicking the row. */ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: PendingRowProps) { + const m = useMessages('userButton'); const { busy, disabled } = useBusy(busyKey); // The button reads the same on every offer and the workspace it acts on is the label beside it, // so pressing tab through the list gives no way to tell them apart without this. @@ -647,6 +653,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe /** What the active account has been asked to join but has not joined yet. */ function PendingRows() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const acceptSuggestion = data.onAcceptSuggestion; const acceptInvitation = data.onAcceptInvitation; @@ -754,6 +761,7 @@ function SessionMenuItem({ session, active }: { session: UserButtonSession; acti * organizations heading carries the spinner for what its own `⋯` opens. */ function SwitchAccountRow() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const addAccount = data.onAddAccount; const { pendingKey } = data; @@ -835,6 +843,7 @@ function SwitchAccountRow() { /** Holds the organization list's place until its first page lands. */ function OrganizationListLoadingRow() { + const m = useMessages('userButton'); return ( // Plain text rather than a live region: it mounts with its copy already in it, so there is no // change for one to report, and the popup it lands in is read on open either way. @@ -857,6 +866,7 @@ function OrganizationListLoadingRow() { * to manage and sign out of itself. */ function OrganizationSection() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const { showOrganizations, showOrganizationsHeading } = data.layout; @@ -924,6 +934,7 @@ interface FooterRow { /** The actions that close out the surface. */ function Footer() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const builtIn: FooterRow[] = []; @@ -1070,8 +1081,9 @@ export function UserButtonTrigger({ renderTriggerLabel = true, renderTriggerBadge = true, }: UserButtonTriggerProps = {}): ReactElement { + const m = useMessages('userButton'); const data = useUserButtonContext(); - const workspace = leadWorkspace(data); + const workspace = leadWorkspace(data, m); const { name, imageUrl, shape } = workspace; const planLabel = renderTriggerBadge && workspace.kind === 'organization' ? workspace.organization.planLabel : undefined; @@ -1110,6 +1122,7 @@ export function UserButtonTrigger({ /** The popover surface: header, organizations, and footer. */ export function UserButtonPopup(): ReactElement { + const m = useMessages('userButton'); const { renderBranding } = useUserButtonContext(); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 2097abcb1fd..985d6683d9a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -1,9 +1,4 @@ /** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userProfileAccountSection', userProfileAccountSectionMessages)`, not of hunting the - * literals down first. - * * Email and phone keep separate keys rather than sharing one templated string: a locale that * inflects around the noun cannot build either from the other. */ @@ -80,4 +75,4 @@ export const userProfileAccountSectionMessages = { cancel: 'Cancel', }, }, -}; +} as const; 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 6a822e98f36..6cec82fe9fe 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 @@ -2,7 +2,7 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, @@ -87,6 +87,7 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const m = useMessages('userProfileAccountSection'); const phoneRow = ( { return Promise.reject(new Error('Add email callbacks are missing')); } -function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : m.error; +function errorMessage(cause: unknown, fallback: string): string | undefined { + if (cause === undefined) { + return undefined; + } + return cause instanceof Error ? cause.message : fallback; } const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; @@ -47,7 +50,7 @@ const machine = createMachine({ onVerify: missingDependency, emailAddress: '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, }, states: { @@ -58,7 +61,7 @@ const machine = createMachine({ actions: assign(context => ({ emailAddress: context.initialEmailAddress ?? '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, })), }, @@ -67,8 +70,8 @@ const machine = createMachine({ email: { on: { CANCEL: 'idle', - TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, errorMessage: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, error: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, }, }, sending: { @@ -76,9 +79,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'email', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -89,12 +90,12 @@ const machine = createMachine({ RESEND: { target: 'resending', guard: context => context.resendSeconds === 0, - actions: assign(() => ({ errorMessage: undefined })), + actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, SUBMIT: { target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), }, }, }, @@ -103,9 +104,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -115,9 +114,7 @@ const machine = createMachine({ onDone: 'idle', onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -127,6 +124,7 @@ const machine = createMachine({ export function useUserProfileAddEmailController( options: UserProfileAddEmailControllerOptions, ): UserProfileAddEmailDialogProps { + const m = useMessages('userProfileAddEmail'); const [snapshot, send] = useMachine(machine, { context: options }); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; @@ -148,7 +146,7 @@ export function useUserProfileAddEmailController( : 'email', emailAddress: snapshot.context.emailAddress, code: snapshot.context.code, - errorMessage: snapshot.context.errorMessage, + errorMessage: errorMessage(snapshot.context.error, m.error), isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), onEmailAddressChange: value => send({ type: 'TYPE_EMAIL', value }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 62cb7f95feb..50d1237fa28 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -10,9 +10,8 @@ import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Input } from '../../../components/input'; import { Otp } from '../../../components/otp'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; export interface UserProfileAddEmailDialogProps { open: boolean; @@ -96,6 +95,7 @@ interface EnterEmailStepProps { } function EnterEmailStep(props: EnterEmailStepProps) { + const m = useMessages('userProfileAddEmail'); const emailFormId = useId(); const handleSubmit = (event: FormEvent) => { @@ -161,6 +161,7 @@ interface VerifyEmailStepProps { } function VerifyEmailStep(props: VerifyEmailStepProps) { + const m = useMessages('userProfileAddEmail'); const verifyFormId = useId(); const handleSubmit = (event: FormEvent) => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts index cfcad0728a0..d665475f099 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts @@ -18,4 +18,4 @@ export const userProfileAddEmailMessages = { resending: 'Sending a new code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts index 6f390d2d032..b5546cba3a7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; +import { useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog'; -import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; export interface UserProfileAddPhoneControllerOptions { initialPhoneNumber?: string; @@ -14,7 +14,7 @@ export interface UserProfileAddPhoneControllerOptions { interface Context extends UserProfileAddPhoneControllerOptions { phoneNumber: string; code: string; - errorMessage: string | undefined; + error: unknown; resendSeconds: number; } @@ -33,8 +33,11 @@ function missingDependency(): Promise { return Promise.reject(new Error('Add phone callbacks are missing')); } -function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : m.error; +function errorMessage(cause: unknown, fallback: string): string | undefined { + if (cause === undefined) { + return undefined; + } + return cause instanceof Error ? cause.message : fallback; } const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; @@ -47,7 +50,7 @@ const machine = createMachine({ onVerify: missingDependency, phoneNumber: '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, }, states: { @@ -58,7 +61,7 @@ const machine = createMachine({ actions: assign(context => ({ phoneNumber: context.initialPhoneNumber ?? '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, })), }, @@ -67,8 +70,8 @@ const machine = createMachine({ phone: { on: { CANCEL: 'idle', - TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, errorMessage: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, error: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, }, }, sending: { @@ -76,9 +79,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'phone', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -89,12 +90,12 @@ const machine = createMachine({ RESEND: { target: 'resending', guard: context => context.resendSeconds === 0, - actions: assign(() => ({ errorMessage: undefined })), + actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, SUBMIT: { target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), }, }, }, @@ -103,9 +104,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -115,9 +114,7 @@ const machine = createMachine({ onDone: 'idle', onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -127,6 +124,7 @@ const machine = createMachine({ export function useUserProfileAddPhoneController( options: UserProfileAddPhoneControllerOptions, ): UserProfileAddPhoneDialogProps { + const m = useMessages('userProfileAddPhone'); const [snapshot, send] = useMachine(machine, { context: options }); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; @@ -148,7 +146,7 @@ export function useUserProfileAddPhoneController( : 'phone', phoneNumber: snapshot.context.phoneNumber, code: snapshot.context.code, - errorMessage: snapshot.context.errorMessage, + errorMessage: errorMessage(snapshot.context.error, m.error), isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx index 815f67d9f71..7296eb59bdc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -11,9 +11,8 @@ import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Otp } from '../../../components/otp'; import { PhoneInput } from '../../../components/phone-input'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; export interface UserProfileAddPhoneDialogProps { open: boolean; @@ -97,6 +96,7 @@ interface EnterPhoneStepProps { } function EnterPhoneStep(props: EnterPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); const phoneFormId = useId(); const handleSubmit = (event: FormEvent) => { @@ -162,6 +162,7 @@ interface VerifyPhoneStepProps { } function VerifyPhoneStep(props: VerifyPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); const verifyFormId = useId(); const handleSubmit = (event: FormEvent) => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts index d9883904dff..89dcf730e81 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts @@ -18,4 +18,4 @@ export const userProfileAddPhoneMessages = { resending: 'Sending a new code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index 1991d2ce147..9f950b2461b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -4,11 +4,10 @@ import { Badge } from '../../../components/badge'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; export interface UserProfileContactListRowViewProps { addAction?: ReactNode; @@ -31,6 +30,7 @@ export function UserProfileContactListRowView({ onRemove, addAction, }: UserProfileContactListRowViewProps) { + const m = useMessages('userProfileAccountSection'); const emptyDescription = m[kind].empty; return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx index b019cad3c40..296335e1604 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx @@ -3,8 +3,8 @@ import type { ReactNode } from 'react'; import { Badge } from '../../../components/badge'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; export interface UserProfileContactRowViewProps { kind: 'email' | 'phone'; @@ -23,6 +23,7 @@ export function UserProfileContactRowView({ onManage, addAction, }: UserProfileContactRowViewProps) { + const m = useMessages('userProfileAccountSection'); const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 8c8fe962f77..589a41516b5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError, UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -54,6 +54,7 @@ export function UserProfileEditNameDialog({ error, onSubmit, }: UserProfileEditNameDialogProps) { + const m = useMessages('userProfileAccountSection'); const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 32b48319f79..7fa55d04cb8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError } from './user-profile-account-section.types'; export type UserProfileEditUsernameField = 'username'; @@ -36,6 +36,7 @@ export function UserProfileEditUsernameDialog({ error, onSubmit, }: UserProfileEditUsernameDialogProps) { + const m = useMessages('userProfileAccountSection'); const formId = useId(); const usernameRef = useRef(null); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 7d2690c775c..2e8ac6b944f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -4,8 +4,7 @@ import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileEmail } from './user-profile-account-section.types'; import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; @@ -36,6 +35,7 @@ export function UserProfileEmailRowView({ onSetPrimaryEmail, onRemoveEmail, }: UserProfileEmailRowViewProps) { + const m = useMessages('userProfileAccountSection'); const addEmailAction = onSendEmailCode && onVerifyEmailCode ? ( @@ -59,6 +60,7 @@ function EditName({ lastNameAttribute?: UserProfileNameAttribute; onSubmit: (value: UserProfileEditNameValue) => Promise; }) { + const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index dfcff9b2c43..545c25899c4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -5,8 +5,7 @@ import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfilePhone } from './user-profile-account-section.types'; import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; @@ -35,6 +34,7 @@ export function UserProfilePhoneRowView({ onSetPrimaryPhone, onRemovePhone, }: UserProfilePhoneRowViewProps) { + const m = useMessages('userProfileAccountSection'); const addPhoneAction = onSendPhoneCode && onVerifyPhoneCode ? ( (); const displayedError = errorMessage ?? rejectionError; const initials = name @@ -93,6 +94,7 @@ function ProfilePictureActions({ canChange: boolean; onRemove?: () => void; }) { + const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); const actions: UserProfileMenuAction[] = []; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 0867aa8cbe9..5e1aefc11f5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,6 +1,6 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; @@ -10,6 +10,7 @@ export interface UserProfileUsernameRowViewProps { } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + const m = useMessages('userProfileAccountSection'); return ( @@ -31,6 +32,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs } function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { + const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx index a57288395db..daac5c2021f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx @@ -7,9 +7,8 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Spinner } from '../../../components/spinner'; import { Text } from '../../../components/text'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles as profileStyles } from '../user-profile-profile-panel.styles'; -import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; import { styles } from './user-profile-verify-email-link.styles'; export interface UserProfileVerifyEmailLinkDialogProps { @@ -33,6 +32,7 @@ export function UserProfileVerifyEmailLinkDialog({ resendSeconds = 0, errorMessage, }: UserProfileVerifyEmailLinkDialogProps) { + const m = useMessages('userProfileVerifyEmailLink'); return ( void; onRemove?: (account: UserProfileConnectedAccount) => void; }) { + const m = useMessages('userProfileConnectedAccounts'); const iconUrl = account.iconUrl?.trim(); const actions: UserProfileMenuAction[] = []; if (account.status === 'reconnect' && onReconnect) { 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 cb763c6276b..67c7729039e 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 @@ -2,9 +2,8 @@ import { useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import { fill, useMessages } from '../../localization'; import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view'; -import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; export interface UserProfileConnectionProvider { id: string; @@ -36,6 +35,7 @@ export function UserProfileConnectedAccountsSectionView({ onReconnect, onRemove, }: UserProfileConnectedAccountsSectionViewProps) { + const m = useMessages('userProfileConnectedAccounts'); const removeAccount = useMemo(() => Confirmation.createHandle(), []); const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts index acf778b26df..a31102ac5fe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts @@ -13,4 +13,4 @@ export const userProfileConnectedAccountsMessages = { confirm: 'Remove', cancel: 'Cancel', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts index 9935c7c8c54..bc32d864fe1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts @@ -1,8 +1,3 @@ -/** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userProfileDeleteSection', userProfileDeleteSectionMessages)`, not of hunting the literals down first. - */ export const userProfileDeleteSectionMessages = { sectionTitle: 'Danger zone', sectionLabel: 'Delete account', @@ -13,4 +8,4 @@ export const userProfileDeleteSectionMessages = { fieldPlaceholder: 'Delete account', actionLabel: 'Delete account', cancelLabel: 'Cancel', -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index a6e27158129..d9c00997b64 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,9 +1,8 @@ import { Destructive } from '../../../blocks/destructive'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; -import { userProfileDeleteSectionMessages as m } from './user-profile-delete-section.messages'; export interface UserProfileDeleteSectionViewProps { /** @@ -14,6 +13,7 @@ export interface UserProfileDeleteSectionViewProps { } export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { + const m = useMessages('userProfileDeleteSection'); const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ onDelete, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx index ee08ce08d82..3bcc4f185c2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -5,8 +5,7 @@ import { Button } from '../../../components/button'; import { Icon, IconFrame } from '../../../components/icon'; import { Section } from '../../../components/section'; import { Spinner } from '../../../components/spinner'; -import { fill } from '../../../utils/messages'; -import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; +import { fill, useMessages } from '../../../localization'; import { styles } from './user-profile-enterprise-accounts-section.styles'; import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-accounts-section.types'; @@ -21,6 +20,7 @@ export function UserProfileEnterpriseAccountRowView({ isPending?: boolean; disabled?: boolean; }) { + const m = useMessages('userProfileEnterpriseAccountsSection'); const iconUrl = account.iconUrl?.trim(); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts index 095f8c062d3..a601b7bada9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -3,4 +3,4 @@ export const userProfileEnterpriseAccountsMessages = { connect: 'Connect', connectProvider: 'Connect {provider}', requiresAction: 'Requires action', -}; +} as const; 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 0c9923801ad..c6321e37602 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 @@ -1,6 +1,6 @@ import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { UserProfileEnterpriseAccountRowView } from './user-profile-enterprise-account-row.view'; -import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; import type { UserProfileEnterpriseAccount, UserProfileEnterpriseConnection, @@ -19,6 +19,7 @@ export function UserProfileEnterpriseAccountsSectionView({ pendingConnectionId, onConnect, }: UserProfileEnterpriseAccountsSectionViewProps) { + const m = useMessages('userProfileEnterpriseAccountsSection'); if (accounts.length === 0 && (connections.length === 0 || !onConnect)) { return null; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 2c15f9fc9fb..2bc37b8e486 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -100,39 +100,14 @@ describe('userProfileEditPasswordMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('Incorrect password.'))); + const failure = new Error('Incorrect password.'); + const actor = start(() => Promise.reject(failure)); fill(actor); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.newPassword).toBe('new-secret-123'); - expect(actor.getSnapshot().context.error).toEqual({ message: 'Incorrect password.', fields: undefined }); - }); - - it('carries field copy through when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your password could not be updated.', { - newPassword: 'Your password must contain 8 or more characters.', - }); - const actor = start(() => Promise.reject(failure)); - fill(actor); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ - newPassword: 'Your password must contain 8 or more characters.', - }), - ); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - fill(actor); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + expect(actor.getSnapshot().context.error).toBe(failure); }); it('refuses to save until both halves match', () => { @@ -234,6 +209,39 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.error).toBeUndefined(); }); + it('carries field copy through when the rejection names the control', async () => { + const failure = new UserProfileSaveError('Your password could not be updated.', { + newPassword: 'Your password must contain 8 or more characters.', + }); + const { result } = renderController(() => Promise.reject(failure)); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSubmit()); + + await waitFor(() => + expect(result.current.error).toEqual({ + message: 'Your password could not be updated.', + fields: { newPassword: 'Your password must contain 8 or more characters.' }, + }), + ); + }); + + it('falls back to generic copy when the rejection is not an Error', async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test + const { result } = renderController(() => Promise.reject('nope')); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSubmit()); + + await waitFor(() => expect(result.current.error?.message).toBe('Something went wrong. Please try again.')); + }); + it('keeps a failed save visible next to a fresh mismatch', async () => { const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 1a143f903ad..e6479db93ac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,8 +1,8 @@ +import { useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-password-section.types'; export interface UserProfileEditPasswordContext { @@ -12,7 +12,7 @@ export interface UserProfileEditPasswordContext { newPassword: string; confirmPassword: string; signOutOfOtherSessions: boolean; - error: UserProfileFormError | undefined; + error: unknown; } export type UserProfileEditPasswordEvent = @@ -48,14 +48,17 @@ export function isSaveable(context: UserProfileEditPasswordContext): boolean { ); } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown, fallback: string): UserProfileFormError | undefined { + if (cause === undefined) { + return undefined; + } if (cause instanceof UserProfileSaveError) { return { message: cause.message, fields: cause.fields }; } if (cause instanceof Error) { return { message: cause.message }; } - return { message: m.errors.generic }; + return { message: fallback }; } export const userProfileEditPasswordMachine = createMachine({ @@ -92,7 +95,7 @@ export const userProfileEditPasswordMachine = createMachine({ onDone: { target: 'idle', actions: assign(() => emptyFields) }, onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: event.error })), }, }, ), @@ -126,13 +129,15 @@ export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { + const m = useMessages('userProfilePasswordSection'); const [snapshot, send] = useMachine(userProfileEditPasswordMachine, { context: { savePassword: onSubmit, requiresCurrentPassword }, }); const { context } = snapshot; + const saveError = toFormError(context.error, m.errors.generic); const error = passwordsMismatch(context) - ? { ...context.error, fields: { ...context.error?.fields, confirmPassword: m.errors.mismatch } } - : context.error; + ? { ...saveError, fields: { ...saveError?.fields, confirmPassword: m.errors.mismatch } } + : saveError; return { isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 1252a652728..77ae0b09c8c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -11,8 +11,8 @@ import { Field } from '../../../components/field'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField } from './user-profile-password-section.types'; @@ -55,6 +55,7 @@ export function UserProfileEditPasswordDialog({ error, onSubmit, }: UserProfileEditPasswordDialogProps) { + const m = useMessages('userProfilePasswordSection'); const formId = useId(); const signOutId = useId(); const signOutDescriptionId = useId(); @@ -202,6 +203,7 @@ function PasswordField({ value: string; onChange: (value: string) => void; }) { + const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); return ( 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 6c7ef41f894..05c8815c162 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 @@ -4,10 +4,9 @@ import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordValue, @@ -21,6 +20,7 @@ export function UserProfilePasswordRowView({ managedBy, onSubmitPassword, }: Omit) { + const m = useMessages('userProfilePasswordSection'); return ( @@ -47,6 +47,7 @@ export function UserProfilePasswordRowView({ } function ManagedByLabel({ name, iconUrl }: UserProfilePasswordManagedBy) { + const m = useMessages('userProfilePasswordSection'); return (
{iconUrl ? ( @@ -83,6 +84,7 @@ function EditPassword({ requiresCurrentPassword: boolean; onSubmit: (value: UserProfileEditPasswordValue) => Promise; }) { + const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, onSubmit, 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 193efff613c..1ad7b77b90b 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 @@ -26,4 +26,4 @@ export const userProfilePasswordSectionMessages = { mismatch: "Passwords don't match.", generic: 'Something went wrong. Please try again.', }, -}; +} as const; 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 c227dc80971..51d736bcdd9 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 @@ -1,6 +1,6 @@ import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { UserProfilePasswordRowView } from './user-profile-password-row.view'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; export type { @@ -11,19 +11,21 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ - sectionTitle = m.sectionTitle, + sectionTitle, hasPassword = false, requiresCurrentPassword = false, managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { + const m = useMessages('userProfilePasswordSection'); + const title = sectionTitle ?? m.sectionTitle; if (!hasPassword && !managedBy && !onSubmitPassword) { return null; } return ( - - {sectionTitle ? {sectionTitle} : null} + + {title ? {title} : null} void; onRemove?: (wallet: UserProfileWeb3Wallet) => void; }) { + const m = useMessages('userProfileWeb3Wallets'); const iconUrl = wallet.iconUrl?.trim(); const linkedWallet = 'address' in wallet ? wallet : undefined; const address = linkedWallet?.address; 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 7cd546b46ba..f07a019108f 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 @@ -2,10 +2,9 @@ import { useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; -import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; export interface UserProfileWeb3Provider { id: string; @@ -40,6 +39,7 @@ export function UserProfileWeb3WalletsSectionView({ onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { + const m = useMessages('userProfileWeb3Wallets'); const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); @@ -73,7 +73,11 @@ export function UserProfileWeb3WalletsSectionView({ + fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { + wallet: truncateWithEndVisible(wallet.address, 13, 4), + }) + } actionLabel={m.removeDialog.confirm} cancelLabel={m.removeDialog.cancel} onConfirm={wallet => onRemove(wallet.id)} @@ -82,9 +86,3 @@ export function UserProfileWeb3WalletsSectionView({ ); } - -function describeWalletRemoval(wallet: UserProfileWeb3Wallet) { - return fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { - wallet: truncateWithEndVisible(wallet.address, 13, 4), - }); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index 451dd43ef11..abdfb66e137 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -15,4 +15,4 @@ export const userProfileWeb3WalletsMessages = { confirm: 'Remove', cancel: 'Cancel', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile.messages.ts b/packages/mosaic/src/features/user-profile/user-profile.messages.ts index 200a5b1a3c8..7f95082be45 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.messages.ts @@ -1,8 +1,3 @@ -/** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userProfile', userProfileMessages)`, not of hunting the literals down first. - */ export const userProfileMessages = { /** Names the surface: its navigation landmark, and the dialog it opens in. */ label: 'User profile', @@ -12,4 +7,4 @@ export const userProfileMessages = { billing: 'Billing', apiKeys: 'API Keys', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 9abdaf968cd..3bcef03478f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { Icon } from '../../components/icon'; import type { ProfileRootProps } from '../../components/profile'; import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; import { getAvailableUserProfilePages, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS } from './user-profile.layout'; -import { userProfileMessages as m } from './user-profile.messages'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; @@ -47,9 +47,10 @@ function BuiltInPage({ id, pages }: { id: UserProfilePageId; pages: UserProfileP * surface blank. */ export const UserProfileView = React.forwardRef(function UserProfileView( - { activePage, pages, customPages, pageOrder, onPageChange, label = m.label, ...rest }, + { activePage, pages, customPages, pageOrder, onPageChange, label, ...rest }, ref, ) { + const m = useMessages('userProfile'); const entries = resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder); const resolvedPage = entries.some(entry => entry.id === activePage) ? activePage : entries[0].id; @@ -60,7 +61,7 @@ export const UserProfileView = React.forwardRef - {label} + {label ?? m.label} {entries.map(entry => ( >; + +type Leaf = string | { readonly other: string }; + +type Nested = { + [K in keyof T]?: T[K] extends string ? string : T[K] extends Leaf ? PluralOverride : Nested; +}; + +type Paths = { + [K in keyof T & string]: T[K] extends Leaf ? `${Prefix}${K}` : Paths; +}[keyof T & string]; + +type At = P extends `${infer Head}.${infer Rest}` + ? Head extends keyof T + ? At + : never + : P extends keyof T + ? T[P] + : never; + +type Flat = { [P in Paths]?: At extends string ? string : PluralOverride }; + +/** + * A set of strings for Mosaic, as nested objects or dot paths. Every key is optional and anything + * omitted falls back to the built-in English. Plural leaves merge per category. + */ +export type MosaicCatalog = Nested & Flat; + +export interface MosaicLocalization { + /** BCP 47 tag that picks plural forms. Defaults to `en`. */ + locale?: string; + /** The catalog for `locale`, applied over English. */ + messages?: MosaicCatalog; + /** Sparse changes applied over `messages`. */ + overrides?: MosaicCatalog; +} diff --git a/packages/mosaic/src/localization/context.tsx b/packages/mosaic/src/localization/context.tsx new file mode 100644 index 00000000000..95aa9ca598f --- /dev/null +++ b/packages/mosaic/src/localization/context.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import type { MosaicLocalization } from './catalog'; +import type { MosaicMessages } from './registry'; +import { mosaicMessages } from './registry'; + +interface MosaicLocalizationValue { + messages: MosaicMessages; + locale: string; +} + +const MosaicLocalizationContext = React.createContext({ + messages: mosaicMessages, + locale: 'en', +}); + +export const MosaicLocalizationProvider = MosaicLocalizationContext.Provider; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function merge(base: unknown, overrides: unknown): unknown { + if (!isRecord(base) || !isRecord(overrides)) { + return overrides ?? base; + } + let result = base; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + continue; + } + const [head, ...rest] = key.split('.'); + const nested = rest.length > 0 ? { [rest.join('.')]: value } : value; + result = { ...result, [head]: merge(result[head], nested) }; + } + return result; +} + +export function resolveLocalization(localization: MosaicLocalization | undefined): MosaicLocalizationValue { + const layers = [localization?.messages, localization?.overrides]; + return { + messages: layers.reduce(merge, mosaicMessages) as MosaicMessages, + locale: localization?.locale ?? 'en', + }; +} + +export function useMessages(namespace: Namespace): MosaicMessages[Namespace] { + return React.useContext(MosaicLocalizationContext).messages[namespace]; +} + +export function useLocale(): string { + return React.useContext(MosaicLocalizationContext).locale; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts new file mode 100644 index 00000000000..fbfc32e732c --- /dev/null +++ b/packages/mosaic/src/localization/index.ts @@ -0,0 +1,6 @@ +export type { MosaicCatalog, MosaicLocalization } from './catalog'; +export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; +export type { MosaicMessages } from './registry'; +export { mosaicMessages } from './registry'; +export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; +export { fill, plural, rich } from './messages'; diff --git a/packages/mosaic/src/localization/localization.test-d.ts b/packages/mosaic/src/localization/localization.test-d.ts new file mode 100644 index 00000000000..fe2150c3c46 --- /dev/null +++ b/packages/mosaic/src/localization/localization.test-d.ts @@ -0,0 +1,83 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import type { MosaicCatalog, MosaicLocalization } from './catalog'; +import { useMessages } from './context'; +import type { MosaicMessages } from './registry'; + +// Message overrides are derived from the message files: nested objects or dot paths, every key +// optional, string leaves stay strings, plural leaves accept any CLDR category. + +describe('MosaicCatalog', () => { + test('accepts nested overrides', () => { + const value: MosaicCatalog = { + userButton: { trigger: { open: 'Menü für {name}' }, workspaces: { members: { few: '{count} Mitglieder' } } }, + }; + void value; + }); + + test('accepts dot paths, mixed with nested', () => { + const value: MosaicCatalog = { + 'userButton.popup.label': 'Konto', + 'userButton.workspaces.members': { many: '{count} членов' }, + userProfile: { label: 'Profil' }, + }; + void value; + }); + + test('rejects unknown keys and paths', () => { + const value: MosaicCatalog = { + // @ts-expect-error typo in the path + 'userButton.popup.lable': 'x', + }; + const nested: MosaicCatalog = { + userButton: { + // @ts-expect-error unknown key + nope: 'x', + }, + }; + void [value, nested]; + }); + + test('keeps a leaf its kind', () => { + const value: MosaicCatalog = { + // @ts-expect-error a string leaf cannot become plural forms + 'userButton.popup.label': { other: 'x' }, + }; + const nested: MosaicCatalog = { + // @ts-expect-error plural forms cannot become a string + userButton: { workspaces: { members: 'x' } }, + }; + void [value, nested]; + }); +}); + +describe('MosaicLocalization', () => { + test('takes a locale, its catalog and overrides on top', () => { + const esES: MosaicCatalog = { 'userButton.popup.label': 'Cuenta' }; + const catalog: MosaicLocalization = { locale: 'es-ES', messages: esES }; + const tweaked: MosaicLocalization = { + locale: 'es-ES', + messages: esES, + overrides: { userButton: { popup: { label: 'Mi cuenta' } } }, + }; + const english: MosaicLocalization = { overrides: { 'userButton.popup.label': 'My account' } }; + void [catalog, tweaked, english]; + }); + + test('checks the catalog and the overrides against the message keys', () => { + const value: MosaicLocalization = { + // @ts-expect-error unknown path + overrides: { 'userButton.popup.lable': 'x' }, + }; + void value; + }); +}); + +describe('useMessages', () => { + test('returns the namespace with its template types', () => { + expectTypeOf(useMessages('userButton')).toEqualTypeOf(); + expectTypeOf(useMessages('userButton').trigger.open).toEqualTypeOf<'Open account menu for {name}'>(); + // @ts-expect-error unknown namespace + useMessages('nope'); + }); +}); diff --git a/packages/mosaic/src/localization/messages.test-d.ts b/packages/mosaic/src/localization/messages.test-d.ts new file mode 100644 index 00000000000..2f4fd7b85d6 --- /dev/null +++ b/packages/mosaic/src/localization/messages.test-d.ts @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import { describe, test } from 'vitest'; + +import { fill, plural, rich } from './messages'; + +// Message files are `as const`, so a template's placeholders are known at the type level and the +// helpers require exactly those params and tags. A plain `string` template keeps the loose shape. + +const m = { + open: 'Open account menu for {name}', + label: 'Account', + members: { one: '{count} member', other: '{count} members' }, + seats: { one: '{count} seat in {org}', other: '{count} seats in {org}' }, + removal: '{#strong}{emailAddress}{/strong} will be removed.', + countdown: 'Resend ({seconds})', + divider: 'before {#rule/} after', +} as const; + +const strong = (children?: ReactNode) => children; + +describe('fill', () => { + test('requires every placeholder and rejects unknown ones', () => { + fill(m.open, { name: 'Sam' }); + // @ts-expect-error name is required + fill(m.open, {}); + // @ts-expect-error nope is not a placeholder + fill(m.open, { name: 'Sam', nope: 1 }); + fill(m.label, {}); + // @ts-expect-error label has no placeholders + fill(m.label, { name: 'Sam' }); + }); + + test('keeps the loose shape for a plain string', () => { + const template: string = m.open; + fill(template, {}); + fill(template, { anything: 1 }); + }); +}); + +describe('plural', () => { + test('supplies count and requires the remaining placeholders', () => { + plural(m.members, 2); + plural(m.members, 2, 'de'); + plural(m.seats, 2, 'en', { org: 'Clerk' }); + // @ts-expect-error seats needs org + plural(m.seats, 2); + // @ts-expect-error members has nothing beyond count + plural(m.members, 2, 'en', { org: 'Clerk' }); + }); +}); + +describe('rich', () => { + test('requires the values and components the template names', () => { + rich(m.removal, { values: { emailAddress: 'a@b.c' }, components: { strong } }); + // @ts-expect-error strong is required + rich(m.removal, { values: { emailAddress: 'a@b.c' } }); + // @ts-expect-error emailAddress is required + rich(m.removal, { components: { strong } }); + rich(m.countdown, { values: { seconds: 9 } }); + rich(m.divider, { components: { rule: () => null } }); + rich(m.label); + // @ts-expect-error label takes no options + rich(m.label, { values: { x: 1 } }); + }); +}); diff --git a/packages/mosaic/src/utils/messages.test.tsx b/packages/mosaic/src/localization/messages.test.tsx similarity index 100% rename from packages/mosaic/src/utils/messages.test.tsx rename to packages/mosaic/src/localization/messages.test.tsx diff --git a/packages/mosaic/src/utils/messages.ts b/packages/mosaic/src/localization/messages.ts similarity index 61% rename from packages/mosaic/src/utils/messages.ts rename to packages/mosaic/src/localization/messages.ts index 35e6fa9d913..f5f35c14161 100644 --- a/packages/mosaic/src/utils/messages.ts +++ b/packages/mosaic/src/localization/messages.ts @@ -12,17 +12,58 @@ export interface RichOptions { components?: MessageComponents; } -function own(record: Record | undefined, key: string): T | undefined { +export type MessageParams = T extends `${string}{${infer Key}}${infer Rest}` + ? Key extends `#${string}` | `/${string}` + ? MessageParams + : Key | MessageParams + : never; + +export type MessageTags = T extends `${string}{#${infer Key}}${infer Rest}` + ? (Key extends `${infer Name}/` ? Name : Key) | MessageTags + : never; + +type Exactly = [K] extends [never] ? Partial> : Record; + +type Values = string extends T ? Record : Exactly, V>; + +type Components = string extends T + ? MessageComponents + : Exactly, MessageComponents[string]>; + +type TypedRichOptions = string extends T + ? [options?: RichOptions] + : [MessageParams] extends [never] + ? [MessageTags] extends [never] + ? [] + : [options: { components: Components }] + : [MessageTags] extends [never] + ? [options: { values: Values }] + : [options: { values: Values; components: Components }]; + +type PluralValues = string extends F['other'] + ? [values?: MessageValues] + : [Exclude, 'count'>] extends [never] + ? [] + : [values: Record, 'count'>, string | number>]; + +function own(record: Partial> | undefined, key: string): T | undefined { return record && Object.hasOwn(record, key) ? record[key] : undefined; } -export function fill(template: string, values: MessageValues): string { +export function fill(template: T, values: Values): string; +export function fill(template: string, values: Partial): string { return template.replace(/\{(\w+)\}/g, (match, key: string) => String(own(values, key) ?? match)); } -export function plural(forms: PluralForms, count: number, locale = 'en'): string { +export function plural( + forms: F, + count: number, + locale = 'en', + ...rest: PluralValues +): string { const category = new Intl.PluralRules(locale).select(count); - return fill(own(forms, category) ?? forms.other, { count }); + const template: string = own(forms, category) ?? forms.other; + return fill(template, { ...rest[0], count }); } type Token = @@ -100,6 +141,7 @@ function fold( return { nodes, next: i, closed: false }; } +export function rich(template: T, ...rest: TypedRichOptions): ReactNode; export function rich(template: string, options: RichOptions = {}): ReactNode { return createElement(Fragment, null, ...fold(tokenize(template), 0, undefined, options).nodes); } diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts new file mode 100644 index 00000000000..ef918abc594 --- /dev/null +++ b/packages/mosaic/src/localization/registry.ts @@ -0,0 +1,31 @@ +import { reverificationMessages } from '../features/reverification/reverification.messages'; +import { userButtonMessages } from '../features/user-button/user-button.messages'; +import { userProfileMessages } from '../features/user-profile/user-profile.messages'; +import { userProfileAccountSectionMessages } from '../features/user-profile/user-profile-account-section/user-profile-account-section.messages'; +import { userProfileAddEmailMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-email.messages'; +import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; +import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; +import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; +import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts.messages'; +import { userProfileDeleteSectionMessages } from '../features/user-profile/user-profile-delete-section/user-profile-delete-section.messages'; +import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; +import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; +import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; + +export const mosaicMessages = { + reverification: reverificationMessages, + userButton: userButtonMessages, + userProfile: userProfileMessages, + userProfileAccountSection: userProfileAccountSectionMessages, + userProfileAddEmail: userProfileAddEmailMessages, + userProfileAddPhone: userProfileAddPhoneMessages, + userProfileVerifyEmailLink: userProfileVerifyEmailLinkMessages, + userProfileVerifyEmailSso: userProfileVerifyEmailSsoMessages, + userProfileConnectedAccounts: userProfileConnectedAccountsMessages, + userProfileDeleteSection: userProfileDeleteSectionMessages, + userProfileEnterpriseAccountsSection: userProfileEnterpriseAccountsMessages, + userProfilePasswordSection: userProfilePasswordSectionMessages, + userProfileWeb3Wallets: userProfileWeb3WalletsMessages, +}; + +export type MosaicMessages = typeof mosaicMessages; diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index a90634273e6..402ebcd5b59 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -56,7 +56,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places an entry in one of these layers. Group order follows first appearance in the `registry` array. The sidebar sorts `Blocks`, `Components`, `Primitives`, `Styles`, and `Hooks` alphabetically by `title`; `User Button`, `User Profile`, and `Reverification` render in registry order. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: +`meta.group` places an entry in one of these layers. Group order follows first appearance in the `registry` array. The sidebar sorts `Blocks`, `Components`, `Primitives`, `Styles`, and `Hooks` alphabetically by `title`; `User Button`, `User Profile`, `Reverification`, and `Localization` render in registry order. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | @@ -66,6 +66,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | | `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | | `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | +| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) | `User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index e4906a3e6aa..0151a0095ad 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -100,6 +100,9 @@ const docModules: Record> = { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), }, + localization: { + localization: dynamic(() => import('../stories/localization.mdx')), + }, }; interface DocsViewerProps { diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 995bb4eae70..6f59f18a3c9 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -26,7 +26,7 @@ import type { StoryStatus, WipSubstatus } from '@/lib/types'; const groups = getSidebarGroups(); -const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks']); +const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks', 'Localization']); type SidebarEntry = ReturnType[number]['components'][number]; @@ -126,12 +126,12 @@ function SidebarEntryMenu({ const href = `/${groupSlug}/${componentSlug}`; // How an entry is USED differs by layer, so the label follows the layer rather // than a guess at the title: hooks are called, atomic styles are a set of - // exports with no single call form worth privileging, and everything else is a - // component rendered as JSX. + // exports with no single call form worth privileging, localization is a prop rather + // than a component, and everything else is a component rendered as JSX. const usage = mod.meta.group === 'Hooks' ? `${mod.meta.title}()` - : mod.meta.group === 'Styles' + : mod.meta.group === 'Styles' || mod.meta.group === 'Localization' ? mod.meta.title : `<${mod.meta.title} />`; return ( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index cdd8a9bbd1f..8be68b24fd2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -100,6 +100,15 @@ import { meta as itemMeta, Scrolling as ItemScrolling, } from '../stories/item.stories'; +import { + Catalog as LocalizationCatalog, + CatalogWithOverrides as LocalizationCatalogWithOverrides, + Fallback as LocalizationFallback, + Helpers as LocalizationHelpers, + meta as localizationMeta, + Overrides as LocalizationOverrides, + PluralRules as LocalizationPluralRules, +} from '../stories/localization.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { @@ -508,6 +517,16 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const localizationModule: StoryModule = { + meta: localizationMeta, + Overrides: LocalizationOverrides, + Catalog: LocalizationCatalog, + CatalogWithOverrides: LocalizationCatalogWithOverrides, + Helpers: LocalizationHelpers, + Fallback: LocalizationFallback, + PluralRules: LocalizationPluralRules, +}; + // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const tableModule: StoryModule = { meta: tableMeta }; const toastModule: StoryModule = { meta: toastMeta }; @@ -730,6 +749,8 @@ export const registry: StoryModule[] = [ scrollAreaModule, // Hooks useDataTableModule, + // Localization + localizationModule, ]; /** diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx new file mode 100644 index 00000000000..0fe4119aa78 --- /dev/null +++ b/packages/swingset/src/stories/localization.mdx @@ -0,0 +1,245 @@ +import * as LocalizationStories from './localization.stories'; + +# Localization + +Every string Mosaic renders comes from a message file next to the feature that uses it, and +`MosaicProvider` takes a `localization` prop that replaces any of them. There are three parts: +a `locale` that picks plural forms, a `messages` catalog for that locale, and sparse `overrides` +for an app's own wording. Anything not supplied falls back to the built-in English, so a catalog +can be partial and an override can be a single key. The whole surface is typed from the message +files themselves, so an unknown key or a missing placeholder is a compile error rather than a +missing string at runtime. + +Without a provider, or with a provider and no `localization`, everything renders in English. + +## Example + +The `UserButton` under a Spanish catalog for its namespace. Open it: the header, the member +counts, the footer rows and the accessible names all come from the catalog. + + + +## Usage + +Change a few English strings: + +```tsx +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; + + + +; +``` + +Ship a language: + +```tsx +import esES from './es-ES.json'; + + + +; +``` + +Ship a language and adjust it for this app: + +```tsx + + +; +``` + +The resolved strings are memoized on the `localization` object, so hoist it out of render or +memoize it rather than writing a fresh literal on every render. + +## Catalogs + +A catalog is a `MosaicCatalog`: the message tree with every key optional. It is keyed by +namespace, one per feature (`userButton`, `userProfile`, `reverification`, …), and within a +namespace the keys mirror the feature's message file. Nested objects and dot paths are +interchangeable, and can be mixed: + +```ts +const overrides: MosaicCatalog = { + userButton: { manage: { invite: 'Invite teammates' } }, + 'userButton.accounts.signOutAll': 'Sign out everywhere', +}; +``` + +A leaf is a string, or a set of plural forms keyed by CLDR category. Placeholders are written +as `{name}`, and inline markup as `{#tag}…{/tag}` or a standalone `{#tag/}`. A translation keeps +the placeholders of the string it replaces; a value for a placeholder the string does not have +is left unused, and a placeholder the values do not cover is rendered literally. + +```json +{ + "userButton": { + "trigger": { "open": "Abrir el menú de la cuenta de {name}" }, + "workspaces": { + "members": { "one": "{count} miembro", "other": "{count} miembros" } + } + }, + "userProfileVerifyEmailLink": { + "resendCountdown": "¿No recibiste el enlace? Reenviar ({seconds})" + } +} +``` + +Plural leaves merge per category, so a catalog that supplies only `many` keeps the other forms +from the layer beneath it. + +The type is derived from the message files with `typeof`, so it is never out of date: a key +that does not exist in Mosaic is rejected, and so is a string where a plural object is expected. +Message keys are therefore public surface, and renaming one is a breaking change for any +catalog that names it. + +## Overrides + +`messages` and `overrides` take the same `MosaicCatalog` shape and differ only in order. English +is the floor, `messages` is applied over it, and `overrides` over that. Keep the language in +`messages` and the app's wording in `overrides`, so a catalog can be swapped or updated without +touching the overrides that sit on top of it. + +A few English strings replaced, one by dot path and one nested: + + + +The Spanish catalog from the example above with one string overridden for this app: + + + +## Locale + +`locale` is a BCP 47 tag and does one thing: it is handed to `Intl.PluralRules`, which picks the +plural category for a count. It defaults to `en`, so an English-only app never sets it. A catalog +does not declare its own language, so set `locale` alongside `messages` whenever the catalog is +not English. + +English has two categories, but Russian and Polish have four and Arabic six, and the rules for +which number takes which form come from CLDR data in the browser rather than from Mosaic. A +catalog names its cases, and the locale decides which one a given count takes: + + + +| Category | Meaning | Required | +| -------- | ------------------------------------------------- | -------- | +| `zero` | Locale-specific zero form | no | +| `one` | Singular | no | +| `two` | Dual | no | +| `few` | Paucal | no | +| `many` | Locale-specific large form | no | +| `other` | The general form, used when no other case matches | yes | + +## Reading strings in a view + +Views never import a message file. Inside the component they read their namespace with +`useMessages`, which returns the resolved object for that namespace and never changes identity +while the `localization` prop is stable, and resolve each string with the helper its +placeholders call for. A module-level helper takes the messages as a parameter. + +```tsx +import { useLocale, useMessages } from '@clerk/mosaic/localization'; +import { fill, plural, rich } from '@clerk/mosaic/localization'; + +function Trigger({ name, count }: { name: string; count: number }) { + const m = useMessages('userButton'); + const locale = useLocale(); + return ( + + ); +} +``` + +| Helper | Resolves | Returns | +| ---------------------------------------- | ------------------------------------ | ----------- | +| `fill(template, values)` | `{name}` placeholders | `string` | +| `plural(forms, count, locale, values?)` | The form for `count`, then `{count}` | `string` | +| `rich(template, { values, components })` | `{name}` and `{#tag}…{/tag}` markup | `ReactNode` | + +Each helper is typed against the literal it is given: `fill` requires exactly the placeholders +the string names, `plural` requires whatever the `other` form names besides `count`, and `rich` +requires a component per tag. Passing a string that is not a literal, such as one read from a +prop, falls back to the untyped signatures. + + + +Fallback is per key. Under the Spanish catalog, which covers only `userButton`, the verify-link +description stays English: + + + +### Adding messages to Mosaic + +- Strings live in a `.messages.ts` next to the feature, exported `as const`, grouped + by the part of the UI that renders them. +- Register the namespace in `mosaicMessages` in `packages/mosaic/src/localization/registry.ts`. + That is what extends `MosaicCatalog` and `useMessages` to the new namespace. +- A count goes through `plural` with a `{ one, other }` leaf, never through string + concatenation, and always with the locale from `useLocale`. +- Markup inside a string goes through `rich` with a `{#tag}` rather than through splitting + the string around the markup. +- A controller or machine stores the cause of an error, not its copy. The hook that turns + the snapshot into props maps the cause to a message with `useMessages`, so the copy follows + the catalog. + +## Props + +`localization` on `MosaicProvider`: + +| Prop | Type | Default | Description | +| ----------- | --------------- | ------- | ------------------------------------------------------------- | +| `locale` | `string` | `'en'` | BCP 47 tag handed to `Intl.PluralRules` to pick plural forms. | +| `messages` | `MosaicCatalog` | — | The catalog for `locale`, applied over English. | +| `overrides` | `MosaicCatalog` | — | Sparse changes applied over `messages`. | + +Exports from `@clerk/mosaic/localization`: + +| Export | Description | +| -------------------- | ---------------------------------------------- | +| `MosaicLocalization` | The `localization` prop's type. | +| `MosaicCatalog` | A partial message tree, nested or by dot path. | +| `MosaicMessages` | The full message tree, as a view reads it. | +| `mosaicMessages` | The built-in English messages. | +| `useMessages(ns)` | The resolved messages for one namespace. | +| `useLocale()` | The active locale. | + +## Design notes + +- **Typed from the source.** There is no schema, no codegen and no runtime validation. The + catalog type is `typeof` the message files, and the helpers read placeholder names out of the + string literals. Mistakes surface in the editor. +- **No message-format library.** ICU MessageFormat syntax needs a parser shipped to the browser. + The part of it that matters, plural selection, is a platform API, and `plural` calls + `Intl.PluralRules` directly. Plural cases are declared as an object rather than inline in a + string, which is what makes a missing form visible in a catalog. +- **One locale at a time.** Catalogs are not keyed by locale and there is no parent-locale + fallback. An app that switches language swaps `messages` and `locale` together, the same way + it swaps the `ClerkProvider` localization resource. +- **Silent fallback.** A key a catalog leaves out renders in English, with no warning. The + types already reject a key that does not exist, and every key exists in English. diff --git a/packages/swingset/src/stories/localization.stories.tsx b/packages/swingset/src/stories/localization.stories.tsx new file mode 100644 index 00000000000..0c7f4123875 --- /dev/null +++ b/packages/swingset/src/stories/localization.stories.tsx @@ -0,0 +1,217 @@ +import { UserButtonView } from '@clerk/mosaic/features/user-button/user-button.view'; +import type { MosaicCatalog } from '@clerk/mosaic/localization'; +import { fill, plural, rich, useLocale, useMessages } from '@clerk/mosaic/localization'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './localization.stories?raw'; + +export const meta: StoryMeta = { + group: 'Localization', + title: 'Localization', + status: 'wip', + source: 'packages/mosaic/src/localization/context.tsx', +}; + +// A catalog for one namespace, the shape an `es-ES.json` would have. Every other namespace is +// left out on purpose: whatever a catalog omits stays English. +const esES: MosaicCatalog = { + userButton: { + trigger: { open: 'Abrir el menú de la cuenta de {name}' }, + popup: { label: 'Cuenta' }, + workspaces: { + personal: 'Cuenta personal', + notSelected: 'Ninguna organización seleccionada', + loading: 'Cargando organizaciones…', + members: { one: '{count} miembro', other: '{count} miembros' }, + accept: 'Aceptar', + join: 'Unirse', + requested: 'Solicitado', + pending: 'pendiente', + }, + accounts: { + actionsFor: 'Acciones para {identifier}', + switch: 'Cambiar de cuenta', + add: 'Añadir cuenta', + signOut: 'Cerrar sesión', + signOutAll: 'Cerrar sesión en todas las cuentas', + }, + manage: { + invite: 'Invitar', + account: 'Gestionar cuenta', + organization: 'Gestionar organización', + createOrganization: 'Crear organización', + }, + }, +}; + +const colin = { + sessionId: 'sess_colin', + name: 'Colin', + identifier: 'colin@clerk.dev', + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', +}; + +const braden = { + sessionId: 'sess_braden', + name: 'Braden', + identifier: 'braden@clerk.dev', + imageUrl: 'https://avatars.githubusercontent.com/u/64913815?v=4', +}; + +const clerkApp = { + kind: 'membership', + organizationId: 'org_clerk_app', + name: 'Clerk app', + membersCount: 24, + planLabel: 'Pro plan', + imageUrl: 'https://avatars.githubusercontent.com/u/49538330?v=4', +} as const; + +const clerkCloud = { + kind: 'membership', + organizationId: 'org_clerk_cloud', + name: 'Clerk Cloud', + membersCount: 1, +} as const; + +// A static user button: every string it renders comes from the `userButton` namespace, so it +// shows a catalog or an override the moment the popup opens. +function Workspace() { + const [open, setOpen] = useState(false); + return ( + setOpen(false)} + onSwitchSession={() => setOpen(false)} + onSignOutAll={() => setOpen(false)} + onManageOrganization={() => setOpen(false)} + onInviteMembers={() => setOpen(false)} + onManageAccount={() => setOpen(false)} + onCreateOrganization={() => setOpen(false)} + onAddAccount={() => setOpen(false)} + /> + ); +} + +function Rows({ rows }: { rows: Array<[string, ReactNode]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +// What a view does with its messages: read the namespace, then resolve each string with the +// helper its placeholders call for. +function Strings() { + const m = useMessages('userButton'); + const link = useMessages('userProfileVerifyEmailLink'); + const locale = useLocale(); + return ( + 30 } })], + ]} + /> + ); +} + +function Counts({ counts }: { counts: number[] }) { + const m = useMessages('userButton'); + const locale = useLocale(); + return [String(count), plural(m.workspaces.members, count, locale)])} />; +} + +export function Overrides() { + return ( + + + + ); +} + +export function Catalog() { + return ( + + + + ); +} + +export function CatalogWithOverrides() { + return ( + + + + ); +} + +export function Helpers() { + return ; +} + +export function Fallback() { + return ( + + + + ); +} + +export function PluralRules() { + return ( + + + + ); +} diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 0c02222de5d..f675e7c881c 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -185,7 +185,7 @@ Supporting files carry the parts that would otherwise bloat those four: ```text user-button.types.ts // the data contract the model and view both agree on -user-button.messages.ts // every string the surface renders, in `@clerk/i18n` shape +user-button.messages.ts // every string the surface renders, as one `as const` object user-button.layout.ts // pure derivation (which affordance goes in which slot) user-button.utils.ts // pure helpers user-button.styles.ts // `stylex.create` atoms (see the StyleX authoring rules) @@ -194,13 +194,25 @@ user-button.styles.ts // `stylex.create` atoms (see the StyleX authoring `*.types.ts` is worth calling out: it holds the data contract so that neither the model nor the view owns it, and the two cannot drift. -`*.messages.ts` holds strings only, exported as one `Messages` object. Views -resolve them with the helpers in `src/mosaic/utils/messages.ts`, never with -`.replace` or `.split` on the string: +`*.messages.ts` holds strings only, exported as one `Messages` object +declared `as const` and registered under a namespace in `src/mosaic/localization/registry.ts`. +Views read their namespace inside the component with `useMessages('userButton')`, so +the `localization` prop on `MosaicProvider` can replace any string; a module-level +helper takes the messages as a parameter rather than importing them. + +```tsx + + +``` + +`messages` is the catalog for `locale`, `overrides` the app's own changes on top, and +English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are resolved +with the helpers in `src/mosaic/localization/messages.ts`, never with `.replace` or `.split` +on the string: - `fill(m.manage, { value })` substitutes `{name}` placeholders. -- `plural(m.members, count)` picks a form from `{ one, other, … }` with - `Intl.PluralRules` and fills `{count}`. +- `plural(m.members, count, locale)` picks a form from `{ one, other, … }` with + `Intl.PluralRules` for the `locale` from `useLocale()` and fills `{count}`. - `rich(m.description, { values, components })` renders `{#strong}…{/strong}` markup through the component authored for that tag, so the sentence stays in the messages file and the view supplies only the element. Markup is for inline @@ -208,6 +220,16 @@ resolve them with the helpers in `src/mosaic/utils/messages.ts`, never with purposes, such as a countdown span, is passed as an element in `values`, so the string only ever sees `{seconds}`. +The helpers are typed from the string itself: `fill` accepts exactly the `{name}` +placeholders the template declares, `rich` exactly the tags it marks up, and +`plural` the placeholders of the `other` form. + +Message keys are public surface. `MosaicCatalog` is derived from the registry, +so a consumer's overrides type-check against the real keys, as a dot path +(`'userButton.popup.label'`) or a nested object (`{ userButton: { popup: { label } } }`). +Renaming or moving a key breaks every override that names it; treat it like renaming +a prop. + ### Composition Two shapes, chosen by whether the slice fetches its own data. @@ -481,9 +503,9 @@ The steps above cover the **styling** migration. For **flow** components — whe | `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | | `src//*.view.tsx` | Clerk-free rendering from plain props | | `src//*.types.ts` | The data contract the model and the view both agree on | -| `src//*.messages.ts` | Every string the surface renders, shaped the way `@clerk/i18n` takes them | +| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | | `src/utils/reset.test.tsx` | Reset specs | -| `src/__tests__/MosaicProvider.test.tsx` | Icon-override context specs | +| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | | `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | | `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | From fcfb8310751d5b888ffdefd6d1e4a8d1828f43e7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 16 Sep 2026 19:25:37 -0400 Subject: [PATCH 2/8] fix(ui): fall back to en when the Mosaic locale is malformed --- .../mosaic/src/__tests__/localization.test.tsx | 6 ++++++ packages/mosaic/src/localization/context.tsx | 14 +++++++++++++- packages/swingset/src/stories/localization.mdx | 3 ++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx index 28a81b58e47..366af8bc304 100644 --- a/packages/mosaic/src/__tests__/localization.test.tsx +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -58,6 +58,12 @@ describe('resolveLocalization', () => { expect(resolveLocalization({ locale: 'es-ES' }).locale).toBe('es-ES'); }); + it('falls back to en when the locale is not a valid BCP 47 tag', () => { + expect(resolveLocalization({ locale: 'en_US' }).locale).toBe('en'); + expect(resolveLocalization({ locale: '' }).locale).toBe('en'); + expect(resolveLocalization({ locale: 'zz' }).locale).toBe('zz'); + }); + it('does not mutate the base messages', () => { resolveLocalization({ messages: { 'userButton.popup.label': 'Konto' } }); expect(mosaicMessages.userButton.popup.label).toBe('Account'); diff --git a/packages/mosaic/src/localization/context.tsx b/packages/mosaic/src/localization/context.tsx index 95aa9ca598f..7f4ae8575a2 100644 --- a/packages/mosaic/src/localization/context.tsx +++ b/packages/mosaic/src/localization/context.tsx @@ -36,11 +36,23 @@ function merge(base: unknown, overrides: unknown): unknown { return result; } +function resolveLocale(locale: string | undefined): string { + if (!locale) { + return 'en'; + } + try { + Intl.getCanonicalLocales(locale); + return locale; + } catch { + return 'en'; + } +} + export function resolveLocalization(localization: MosaicLocalization | undefined): MosaicLocalizationValue { const layers = [localization?.messages, localization?.overrides]; return { messages: layers.reduce(merge, mosaicMessages) as MosaicMessages, - locale: localization?.locale ?? 'en', + locale: resolveLocale(localization?.locale), }; } diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx index 0fe4119aa78..c308022002a 100644 --- a/packages/swingset/src/stories/localization.mdx +++ b/packages/swingset/src/stories/localization.mdx @@ -128,7 +128,8 @@ The Spanish catalog from the example above with one string overridden for this a `locale` is a BCP 47 tag and does one thing: it is handed to `Intl.PluralRules`, which picks the plural category for a count. It defaults to `en`, so an English-only app never sets it. A catalog does not declare its own language, so set `locale` alongside `messages` whenever the catalog is -not English. +not English. A value that is not a well-formed tag, such as `en_US` with an underscore, is treated +as `en` rather than thrown at render time. English has two categories, but Russian and Polish have four and Arabic six, and the rules for which number takes which form come from CLDR data in the browser rather than from Mosaic. A From 66cbd5f740c20a0d33a885dd288d2fcf4af89755 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 16 Sep 2026 19:28:02 -0400 Subject: [PATCH 3/8] fix(ui): require plural placeholders from every form and correct Mosaic localization docs --- .../src/localization/messages.test-d.ts | 4 ++ packages/mosaic/src/localization/messages.ts | 6 +- .../swingset/src/stories/localization.mdx | 2 +- references/mosaic-architecture.md | 55 ++++++++++--------- 4 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/mosaic/src/localization/messages.test-d.ts b/packages/mosaic/src/localization/messages.test-d.ts index 2f4fd7b85d6..10509fcecd1 100644 --- a/packages/mosaic/src/localization/messages.test-d.ts +++ b/packages/mosaic/src/localization/messages.test-d.ts @@ -11,6 +11,7 @@ const m = { label: 'Account', members: { one: '{count} member', other: '{count} members' }, seats: { one: '{count} seat in {org}', other: '{count} seats in {org}' }, + invites: { one: '{count} invite from {sender}', other: '{count} invites' }, removal: '{#strong}{emailAddress}{/strong} will be removed.', countdown: 'Resend ({seconds})', divider: 'before {#rule/} after', @@ -46,6 +47,9 @@ describe('plural', () => { plural(m.seats, 2); // @ts-expect-error members has nothing beyond count plural(m.members, 2, 'en', { org: 'Clerk' }); + plural(m.invites, 1, 'en', { sender: 'Sam' }); + // @ts-expect-error a placeholder named by any form is required + plural(m.invites, 2); }); }); diff --git a/packages/mosaic/src/localization/messages.ts b/packages/mosaic/src/localization/messages.ts index f5f35c14161..1fd11041345 100644 --- a/packages/mosaic/src/localization/messages.ts +++ b/packages/mosaic/src/localization/messages.ts @@ -40,11 +40,13 @@ type TypedRichOptions = string extends T ? [options: { values: Values }] : [options: { values: Values; components: Components }]; +type PluralParams = Exclude>, 'count'>; + type PluralValues = string extends F['other'] ? [values?: MessageValues] - : [Exclude, 'count'>] extends [never] + : [PluralParams] extends [never] ? [] - : [values: Record, 'count'>, string | number>]; + : [values: Record, string | number>]; function own(record: Partial> | undefined, key: string): T | undefined { return record && Object.hasOwn(record, key) ? record[key] : undefined; diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx index c308022002a..7b45e18b1bf 100644 --- a/packages/swingset/src/stories/localization.mdx +++ b/packages/swingset/src/stories/localization.mdx @@ -178,7 +178,7 @@ function Trigger({ name, count }: { name: string; count: number }) { | `rich(template, { values, components })` | `{name}` and `{#tag}…{/tag}` markup | `ReactNode` | Each helper is typed against the literal it is given: `fill` requires exactly the placeholders -the string names, `plural` requires whatever the `other` form names besides `count`, and `rich` +the string names, `plural` requires whatever any of the forms name besides `count`, and `rich` requires a component per tag. Passing a string that is not a literal, such as one read from a prop, falls back to the untyped signatures. diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index f675e7c881c..ce0149ce5a7 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -74,7 +74,7 @@ State styling uses real class + attribute-selector specificity — no `&&` boost ## MosaicProvider -Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for one thing: per-name icon glyph overrides. +Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for two things: per-name icon glyph overrides and localization. ```tsx import { MosaicProvider } from './MosaicProvider'; @@ -195,7 +195,7 @@ user-button.styles.ts // `stylex.create` atoms (see the StyleX authoring model nor the view owns it, and the two cannot drift. `*.messages.ts` holds strings only, exported as one `Messages` object -declared `as const` and registered under a namespace in `src/mosaic/localization/registry.ts`. +declared `as const` and registered under a namespace in `src/localization/registry.ts`. Views read their namespace inside the component with `useMessages('userButton')`, so the `localization` prop on `MosaicProvider` can replace any string; a module-level helper takes the messages as a parameter rather than importing them. @@ -207,10 +207,10 @@ helper takes the messages as a parameter rather than importing them. `messages` is the catalog for `locale`, `overrides` the app's own changes on top, and English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are resolved -with the helpers in `src/mosaic/localization/messages.ts`, never with `.replace` or `.split` +with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split` on the string: -- `fill(m.manage, { value })` substitutes `{name}` placeholders. +- `fill(m.manage, { name })` substitutes `{name}` placeholders. - `plural(m.members, count, locale)` picks a form from `{ one, other, … }` with `Intl.PluralRules` for the `locale` from `useLocale()` and fills `{count}`. - `rich(m.description, { values, components })` renders `{#strong}…{/strong}` @@ -485,29 +485,30 @@ The steps above cover the **styling** migration. For **flow** components — whe ## Files -| File | Purpose | -| --------------------------------------- | --------------------------------------------------------------------------- | -| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | -| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | -| `src/MosaicProvider.tsx` | Provider for the `icons` prop (per-name glyph overrides) | -| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | -| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | -| `src/components/` | One subdirectory per component, and nothing else | -| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | -| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | -| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | -| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | -| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | -| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | -| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | -| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | -| `src//*.view.tsx` | Clerk-free rendering from plain props | -| `src//*.types.ts` | The data contract the model and the view both agree on | -| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | -| `src/utils/reset.test.tsx` | Reset specs | -| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | -| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | -| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | +| File | Purpose | +| ---------------------------------------------- | ---------------------------------------------------------------------------------- | +| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | +| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | +| `src/MosaicProvider.tsx` | Provider for the `icons` and `localization` props | +| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | +| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | +| `src/localization/` | Message registry, `MosaicCatalog` types, context hooks, and `fill`/`plural`/`rich` | +| `src/components/` | One subdirectory per component, and nothing else | +| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | +| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | +| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | +| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | +| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | +| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | +| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | +| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | +| `src//*.view.tsx` | Clerk-free rendering from plain props | +| `src//*.types.ts` | The data contract the model and the view both agree on | +| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | +| `src/utils/reset.test.tsx` | Reset specs | +| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | +| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | +| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | `machine/` is the runtime; `machines/` is machines written with it. The one-letter difference is easy to misread — a feature's own machine belongs in its From ca3aad052dc30e8711384c353febd9531676499a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 11:45:08 -0400 Subject: [PATCH 4/8] chore(mosaic): add changeset for MosaicProvider localization --- .changeset/mosaic-provider-localization.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/mosaic-provider-localization.md b/.changeset/mosaic-provider-localization.md index a845151cc84..bee53962db6 100644 --- a/.changeset/mosaic-provider-localization.md +++ b/.changeset/mosaic-provider-localization.md @@ -1,2 +1,5 @@ --- +'@clerk/mosaic': minor --- + +Add a `localization` prop to `MosaicProvider` for supplying a translated message catalog. Pass `messages` for a full or partial catalog, `overrides` for sparse changes on top of it, and `locale` to pick plural forms. Any key a catalog omits falls back to the built-in English strings. From f8fd25b28d13968822bbae15776c4221c7a6f308 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 12:04:06 -0400 Subject: [PATCH 5/8] docs(mosaic): correct localization examples and the user profile label default --- .../user-profile/user-profile.view.tsx | 2 +- .../swingset/src/stories/localization.mdx | 2 + references/mosaic-architecture.md | 60 ++++++++++--------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 3bcef03478f..3e200f1eec1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -12,7 +12,7 @@ import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; export interface UserProfileViewProps extends Omit { - /** Names the surface, and the dialog it opens in. Defaults to English; pass a localized string once one is available. */ + /** Names the surface, and the dialog it opens in. Defaults to the `userProfile.label` message. */ label?: string; /** The open page: a built-in page's id, or a custom page's `path`. */ activePage: UserProfilePageId | (string & {}); diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx index 7b45e18b1bf..f27d877579d 100644 --- a/packages/swingset/src/stories/localization.mdx +++ b/packages/swingset/src/stories/localization.mdx @@ -69,6 +69,8 @@ namespace the keys mirror the feature's message file. Nested objects and dot pat interchangeable, and can be mixed: ```ts +import type { MosaicCatalog } from '@clerk/mosaic/localization'; + const overrides: MosaicCatalog = { userButton: { manage: { invite: 'Invite teammates' } }, 'userButton.accounts.signOutAll': 'Sign out everywhere', diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index ce0149ce5a7..f399ea47e80 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -201,8 +201,12 @@ the `localization` prop on `MosaicProvider` can replace any string; a module-lev helper takes the messages as a parameter rather than importing them. ```tsx - - +{children}; + + {children} +; ``` `messages` is the catalog for `locale`, `overrides` the app's own changes on top, and @@ -210,7 +214,7 @@ English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are res with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split` on the string: -- `fill(m.manage, { name })` substitutes `{name}` placeholders. +- `fill(m.trigger.open, { name })` substitutes `{name}` placeholders. - `plural(m.members, count, locale)` picks a form from `{ one, other, … }` with `Intl.PluralRules` for the `locale` from `useLocale()` and fills `{count}`. - `rich(m.description, { values, components })` renders `{#strong}…{/strong}` @@ -222,7 +226,7 @@ on the string: The helpers are typed from the string itself: `fill` accepts exactly the `{name}` placeholders the template declares, `rich` exactly the tags it marks up, and -`plural` the placeholders of the `other` form. +`plural` whatever any of the forms name besides `count`. Message keys are public surface. `MosaicCatalog` is derived from the registry, so a consumer's overrides type-check against the real keys, as a dot path @@ -485,30 +489,30 @@ The steps above cover the **styling** migration. For **flow** components — whe ## Files -| File | Purpose | -| ---------------------------------------------- | ---------------------------------------------------------------------------------- | -| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | -| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | -| `src/MosaicProvider.tsx` | Provider for the `icons` and `localization` props | -| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | -| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | -| `src/localization/` | Message registry, `MosaicCatalog` types, context hooks, and `fill`/`plural`/`rich` | -| `src/components/` | One subdirectory per component, and nothing else | -| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | -| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | -| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | -| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | -| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | -| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | -| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | -| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | -| `src//*.view.tsx` | Clerk-free rendering from plain props | -| `src//*.types.ts` | The data contract the model and the view both agree on | -| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | -| `src/utils/reset.test.tsx` | Reset specs | -| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | -| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | -| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | +| File | Purpose | +| --------------------------------------- | ---------------------------------------------------------------------------------- | +| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | +| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | +| `src/MosaicProvider.tsx` | Provider for the `icons` and `localization` props | +| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | +| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | +| `src/localization/` | Message registry, `MosaicCatalog` types, context hooks, and `fill`/`plural`/`rich` | +| `src/components/` | One subdirectory per component, and nothing else | +| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | +| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | +| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | +| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | +| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | +| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | +| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | +| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | +| `src//*.view.tsx` | Clerk-free rendering from plain props | +| `src//*.types.ts` | The data contract the model and the view both agree on | +| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | +| `src/utils/reset.test.tsx` | Reset specs | +| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | +| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | +| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | `machine/` is the runtime; `machines/` is machines written with it. The one-letter difference is easy to misread — a feature's own machine belongs in its From cf1336d950afd3b3b284f6a5841f6b525a3a2014 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 13:50:24 -0400 Subject: [PATCH 6/8] feat(mosaic): export MosaicProvider from the package entry --- .changeset/mosaic-provider-localization.md | 2 +- packages/mosaic/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/mosaic-provider-localization.md b/.changeset/mosaic-provider-localization.md index bee53962db6..249639f5d2c 100644 --- a/.changeset/mosaic-provider-localization.md +++ b/.changeset/mosaic-provider-localization.md @@ -2,4 +2,4 @@ '@clerk/mosaic': minor --- -Add a `localization` prop to `MosaicProvider` for supplying a translated message catalog. Pass `messages` for a full or partial catalog, `overrides` for sparse changes on top of it, and `locale` to pick plural forms. Any key a catalog omits falls back to the built-in English strings. +Export `MosaicProvider` and add a `localization` prop to it for supplying a translated message catalog. Wrap `UserButton` in the provider and pass `messages` for a full or partial catalog, `overrides` for sparse changes on top of it, and `locale` to pick plural forms. Any key a catalog omits falls back to the built-in English strings. diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index c9db826cd1a..ff3783da6f5 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -2,3 +2,4 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; +export { MosaicProvider } from './MosaicProvider'; From 002becf25b68ed4b3b10894762134b776ed55d5e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 13:51:13 -0400 Subject: [PATCH 7/8] chore(mosaic): simplify the MosaicProvider changeset --- .changeset/mosaic-provider-localization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mosaic-provider-localization.md b/.changeset/mosaic-provider-localization.md index 249639f5d2c..6db774ce003 100644 --- a/.changeset/mosaic-provider-localization.md +++ b/.changeset/mosaic-provider-localization.md @@ -2,4 +2,4 @@ '@clerk/mosaic': minor --- -Export `MosaicProvider` and add a `localization` prop to it for supplying a translated message catalog. Wrap `UserButton` in the provider and pass `messages` for a full or partial catalog, `overrides` for sparse changes on top of it, and `locale` to pick plural forms. Any key a catalog omits falls back to the built-in English strings. +Export `MosaicProvider` to enable customizing messages and icons within the new components. From 77c0fccd5985af8ad5b9a55a7925f1e1bad2914a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 14:07:37 -0400 Subject: [PATCH 8/8] chore(mosaic): raise the index bundle limit for the MosaicProvider export --- packages/mosaic/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/bundlewatch.config.json b/packages/mosaic/bundlewatch.config.json index e6978792667..2325ac6ed4a 100644 --- a/packages/mosaic/bundlewatch.config.json +++ b/packages/mosaic/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/index.js", "maxSize": "107KB" }, + { "path": "./dist/index.js", "maxSize": "110KB" }, { "path": "./dist/styles.css", "maxSize": "12KB" } ] }