Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/swingset-faker-chaos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/swingset/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ These require reading several files together; the `README.md` covers the step-by

- **Every story renders inside `MosaicProvider`.** `StoryPreview` (the MDX `<Preview>`) renders a named story with the playground's knob values as props and exposes a Reset button. `StoryEmbed` (the MDX `<Story>`) renders a single static variation with default knob values and no controls.
- The header's **RTL** switch (`DirectionToggle`, state in `DirectionProvider`) sets `dir` on `<html>`. It has to sit that high because popover and menu popups portal to `<body>`, so a `dir` on the preview container would never reach them; swingset's own chrome flips along with the story.
- The header's **Chaos** switch (`ChaosToggle`, state in `ChaosProvider`) stress-tests layouts with worst-case copy and data. `StoryMosaicProvider` (used by both story renderers) passes `chaosLocalization` from `lib/chaos.ts` as the `localization` prop, and remounts the story on toggle so fixture state reseeds. That catalog is generated from `mosaicMessages`, so new messages need no wiring. Fixtures opt their seed data in with `useChaosFixture(data, toChaos)`, building the chaos version from the helpers in `lib/chaos.ts` (`chaosName`, `chaosEmail`, `chaosText`, `chaosRows`). When you add a fixture, wrap its seed data the same way. Stories that mount their own `MosaicProvider` (the Localization page) ignore the catalog.
- The header's **Chaos** switch (`ChaosToggle`, state in `ChaosProvider`) stress-tests layouts with worst-case data that still reads as real: long names, companies, emails and places across scripts and RTL, plus long row counts. `StoryMosaicProvider` (used by both story renderers) remounts the story on toggle so fixture state reseeds. UI copy stays the built-in English. Fixtures opt their seed data in with `useChaosFixture(data, toChaos)`, building the chaos version from the helpers in `lib/chaos.ts`: seeded `@faker-js/faker` generators (`chaosName`, `chaosPerson`, `chaosUsername`, `chaosEmail`, `chaosCompany`, `chaosSlug`, `chaosPhone`, `chaosPlace`), which take the longest of several samples across locales, plus `chaosRole`, `chaosPick` for hand-written lists faker can't supply, and `chaosRows`. Never stretch copy with filler; pick a longer value someone would actually type. When you add a fixture, wrap its seed data the same way.

- **The prop table is the knob surface.** `PropTable` (MDX `<PropTable>`) derives rows from `meta.styles._variants`/`_defaultVariants`, then appends the `className` + `style` escape-hatch rows every Mosaic component accepts. Each variant row renders a `KnobControl` in its **Value** column, seeded with the prop's default and bound to the playground context. The escape-hatch rows and `extra` stay static.

Expand Down
1 change: 1 addition & 0 deletions packages/swingset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@clerk/mosaic": "workspace:*",
"@clerk/nextjs": "workspace:*",
"@clerk/shared": "workspace:*",
"@faker-js/faker": "^10.0.0",
"@floating-ui/react": "catalog:repo",
"@stylexjs/stylex": "0.19.0",
"@tailwindcss/typography": "^0.5.19",
Expand Down
3 changes: 0 additions & 3 deletions packages/swingset/src/components/ChaosProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
import * as React from 'react';

import { chaosLocalization } from '@/lib/chaos';

import { useDirection } from './DirectionProvider';

interface ChaosContextValue {
Expand Down Expand Up @@ -36,7 +34,6 @@ export function StoryMosaicProvider({ children }: { children: React.ReactNode })
<MosaicProvider
key={String(chaos)}
direction={direction}
localization={chaos ? chaosLocalization : undefined}
>
{children}
</MosaicProvider>
Expand Down
105 changes: 65 additions & 40 deletions packages/swingset/src/lib/chaos.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,72 @@
import type { MosaicLocalization } from '@clerk/mosaic/localization';
import { mosaicMessages } from '@clerk/mosaic/localization';

const CHAOS_SUFFIX = 'Ŵïţħ ëẍţŕą łöñğ ţêẍţ ţħąţ ẅŕąṗṡ Pneumonoultramicroscopicsilicovolcanoconiosis';

const CHAOS_NAMES = [
'Maximiliana-Alexandrina Wolfeschlegelsteinhausenbergerdorff-Montgomery',
'X',
'محمد بن عبد الرحمن آل سعود',
'李小龍',
'Zoë Ångström-Ñúñez',
"Seán O'Brien-Smith 🦄🚀",
'Z̴̢a̷̛l̸̨g̵̢o̶̧ ̷̨T̵̛e̸̢x̴̨t̶̛',
'ALLCAPSNAMEWITHOUTANYSPACESTHATJUSTKEEPSGOINGANDGOINGANDGOING',
'<b>Not Bold</b> & "Quoted"',
];
import type { Faker } from '@faker-js/faker';
import { fakerAR, fakerDE, fakerFI, fakerFR, fakerJA, fakerPT_BR, fakerVI, fakerZH_CN } from '@faker-js/faker';

const LOCALES = [fakerDE, fakerAR, fakerFI, fakerJA, fakerPT_BR, fakerVI, fakerFR, fakerZH_CN];

const SAMPLES = 20;

const CHAOS_EMAILS = [
'maximiliana.alexandrina.wolfeschlegelsteinhausenbergerdorff+notifications@engineering.eu-west.very-long-company-name.example.com',
'x@y.co',
'first.last+tag+another-tag@xn--80ak6aa92e.com',
'"quoted local part"@example.com',
'用户@例子.广告',
'ALL.CAPS.ADDRESS@EXAMPLE.COM',
const ROLES = [
{
label: 'Billing administrator',
description: 'Can manage the subscription, payment methods and invoices, and export billing history.',
},
{
label: 'Customer success lead (EMEA)',
description: 'Can view every member and organization setting, and invite people to regional workspaces.',
},
{
label: 'Read-only auditor',
description: 'Can view members, roles, API keys and audit logs, but cannot change anything.',
},
];

export function chaosText(text: string): string;
export function chaosText(text: string | undefined): string | undefined;
export function chaosText(text: string | undefined) {
return text === undefined ? undefined : `${text} ${CHAOS_SUFFIX}`;
export function chaosName(index: number) {
return longest(index, f => f.person.fullName());
}

export function chaosName(index: number) {
return CHAOS_NAMES[index % CHAOS_NAMES.length];
export function chaosPerson(index: number) {
return {
firstName: longest(index, f => f.person.firstName()),
lastName: longest(index, f => f.person.lastName()),
};
}

export function chaosUsername(index: number) {
return longest(index, f => f.internet.username());
}

export function chaosEmail(index: number) {
return CHAOS_EMAILS[index % CHAOS_EMAILS.length];
return longest(index, f =>
f.internet.email({
firstName: f.person.firstName(),
lastName: f.person.lastName(),
provider: f.internet.domainName(),
}),
);
}

export function chaosCompany(index: number) {
return longest(index, f => f.company.name());
}

export function chaosPhone(index: number) {
return longest(index, f => f.phone.number({ style: 'international' }));
}

export function chaosSlug(index: number) {
return `${chaosPick(LOCALES, index).helpers.slugify(chaosCompany(index)).toLowerCase()}-177654156132154`;
}

export function chaosPlace(index: number) {
return longest(index, f => `${f.location.city()}, ${f.location.state()}, ${f.location.country()}`);
}

export function chaosRole(index: number) {
return chaosPick(ROLES, index);
}

export function chaosPick<T>(values: readonly T[], index: number) {
return values[index % values.length];
}

export function chaosRows<T extends { id: string }>(items: readonly T[], count = 60) {
Expand All @@ -48,14 +79,8 @@ export function chaosRows<T extends { id: string }>(items: readonly T[], count =
});
}

type Stretched<T> = { [K in keyof T]: T[K] extends string ? string : Stretched<T[K]> };

function stretch<T extends object>(tree: T): Stretched<T> {
const entries = Object.entries(tree).map(([key, value]) => [
key,
typeof value === 'string' ? chaosText(value) : stretch(value),
]);
return Object.fromEntries(entries) as Stretched<T>;
function longest(index: number, make: (faker: Faker) => string) {
const faker = chaosPick(LOCALES, index);
faker.seed(index);
return Array.from({ length: SAMPLES }, () => make(faker)).reduce((a, b) => (b.length > a.length ? b : a));
}

export const chaosLocalization: MosaicLocalization = { messages: stretch(mosaicMessages) };
13 changes: 11 additions & 2 deletions packages/swingset/src/stories/fixtures/api-keys-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useLocale, useMessages } from '@clerk/mosaic/localization';
import { useEffect, useRef, useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosRows, chaosText } from '@/lib/chaos';
import { chaosPick, chaosRows } from '@/lib/chaos';

interface FixtureAPIKey {
id: string;
Expand All @@ -25,6 +25,15 @@ interface FixtureAPIKey {

const exampleTime = Date.now();

const chaosAPIKeyNames = [
'Production webhook ingestion for the EU billing reconciliation pipeline',
'Nightly data warehouse export (Snowflake to BigQuery)',
'Customer support dashboard, read-only access',
'Legacy v1 API: do not revoke until the mobile migration ships',
'Zapier',
'Internal tooling for the Growth and Lifecycle Marketing team',
];

export const exampleAPIKeys: FixtureAPIKey[] = [
'Web app',
'Mobile app',
Expand Down Expand Up @@ -67,7 +76,7 @@ export function useAPIKeysTableFixture({
const locale = useLocale();
const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), subjectKind);
const seed = useChaosFixture(initialKeys, items =>
chaosRows(items).map(key => ({ ...key, name: chaosText(key.name) })),
chaosRows(items).map((key, index) => ({ ...key, name: chaosPick(chaosAPIKeyNames, index) })),
);
const [items, setItems] = useState(seed);
const [searchValue, setSearchValue] = useState('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useLocale } from '@clerk/mosaic/localization';
import { useRef, useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos';
import { chaosEmail, chaosRole, chaosRows } from '@/lib/chaos';

const exampleInvitations = [
'ada',
Expand Down Expand Up @@ -36,7 +36,7 @@ export function useInvitationsTableFixture({ proposed = false, empty = false } =
chaosRows(items).map((invitation, index) => ({
...invitation,
email: chaosEmail(index),
roleLabel: chaosText(invitation.roleLabel),
roleLabel: chaosRole(index).label,
})),
);
const [items, setItems] = useState(empty ? [] : invitations);
Expand Down
6 changes: 4 additions & 2 deletions packages/swingset/src/stories/fixtures/members-table-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useLocale } from '@clerk/mosaic/localization';
import { useRef, useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosName, chaosRows, chaosText } from '@/lib/chaos';
import { chaosEmail, chaosName, chaosRole, chaosRows } from '@/lib/chaos';

const exampleRoles = [
{ value: 'admin', label: 'Admin' },
Expand Down Expand Up @@ -38,7 +38,9 @@ const exampleMembers = [

export function useMembersTableFixture({ proposed = false, empty = false } = {}): MembersTableTabViewProps {
const locale = useLocale();
const roles = useChaosFixture(exampleRoles, items => items.map(role => ({ ...role, label: chaosText(role.label) })));
const roles = useChaosFixture(exampleRoles, items =>
items.map((role, index) => ({ ...role, label: chaosRole(index).label })),
);
const seed = useChaosFixture(exampleMembers, items =>
chaosRows(items).map((member, index) => ({
...member,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { MouseEvent } from 'react';
import { useRef, useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosText } from '@/lib/chaos';
import { chaosRole } from '@/lib/chaos';

const exampleRoles = [
{ value: 'member', label: 'Member', description: 'Role with non-privileged permissions in the organization.' },
Expand All @@ -20,9 +20,7 @@ export function useInviteMembersFixture(): {
} {
const m = useMessages('organizationProfileInviteMembers');
const locale = useLocale();
const roles = useChaosFixture(exampleRoles, items =>
items.map(role => ({ ...role, label: chaosText(role.label), description: chaosText(role.description) })),
);
const roles = useChaosFixture(exampleRoles, items => items.map((role, index) => ({ ...role, ...chaosRole(index) })));
const toast = useToastManager();
const trigger = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { SaveError } from '@clerk/mosaic/utils/errors';
import { useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosName } from '@/lib/chaos';
import { chaosCompany, chaosSlug } from '@/lib/chaos';

import { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table';
import { useInvitationsTableFixture } from './invitations-table-tab';
Expand Down Expand Up @@ -44,8 +44,8 @@ export interface OrganizationProfileFixtureOptions {
export function useOrganizationProfileFixture({ failWith }: OrganizationProfileFixtureOptions = {}) {
const [activePage, setActivePage] = useState<OrganizationProfileViewProps['activePage']>('general');
const organization = useChaosFixture(exampleOrganization, () => ({
name: chaosName(0),
slug: 'a-really-long-organization-slug-that-never-seems-to-end-177654156132154',
name: chaosCompany(0),
slug: chaosSlug(0),
memberCount: 1_234_567,
}));
const [name, setName] = useState(organization.name);
Expand Down
18 changes: 12 additions & 6 deletions packages/swingset/src/stories/fixtures/switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosName, chaosText } from '@/lib/chaos';
import { chaosCompany, chaosEmail, chaosName } from '@/lib/chaos';

// The data behind the design frames: one account, Cameron Walker, signed in three times over, and
// the organizations each frame lists. Only the flagship organizations carry the Clerk mark; the rest wear
Expand Down Expand Up @@ -109,14 +109,20 @@ function chaosAccounts(accounts: Account[]): Account[] {
return accounts.map((account, index) => ({
...account,
session: { ...account.session, name: chaosName(index), identifier: chaosEmail(index) },
memberships: account.memberships.map(m => ({
memberships: account.memberships.map((m, membershipIndex) => ({
...m,
name: chaosText(m.name),
planLabel: chaosText(m.planLabel),
name: chaosCompany(index * 10 + membershipIndex),
planLabel: 'Enterprise (annual, invoiced)',
membersCount: 1_234_567,
})),
suggestions: account.suggestions.map(s => ({ ...s, name: chaosText(s.name) })),
invitations: account.invitations.map(i => ({ ...i, organizationName: chaosText(i.organizationName) })),
suggestions: account.suggestions.map((s, suggestionIndex) => ({
...s,
name: chaosCompany(index * 10 + suggestionIndex + 4),
})),
invitations: account.invitations.map((i, invitationIndex) => ({
...i,
organizationName: chaosCompany(index * 10 + invitationIndex + 7),
})),
}));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useUserProfileActiveDevicesController } from '@clerk/mosaic/features/us
import { useRef, useState } from 'react';

import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosText } from '@/lib/chaos';
import { chaosPick, chaosPlace } from '@/lib/chaos';

export const userProfileDevices: UserProfileDevice[] = [
{
Expand Down Expand Up @@ -75,6 +75,20 @@ export const userProfileImpersonationDevices: UserProfileDevice[] = [
},
];

const chaosDevices = [
{
name: 'Microsoft Edge on Windows 11',
model: 'Lenovo ThinkPad X1 Carbon Gen 12',
browser: 'Microsoft Edge 150.0.3240.92',
},
{ name: 'Samsung Internet on Android', model: 'Samsung Galaxy Z Fold6', browser: 'Samsung Internet 28.0.1.42' },
{
name: 'Firefox Developer Edition on Ubuntu',
model: 'Framework Laptop 16',
browser: 'Firefox Developer Edition 152.0b9',
},
];

export interface UserProfileActiveDevicesFixtureOptions {
devices?: UserProfileDevice[];
/** How long a sign out takes, so the pending button and its spinner are visible. */
Expand All @@ -89,13 +103,11 @@ export function useUserProfileActiveDevicesFixture({
failWith,
}: UserProfileActiveDevicesFixtureOptions = {}) {
const seed = useChaosFixture(initialDevices, items =>
items.map(device => ({
items.map((device, index) => ({
...device,
name: chaosText(device.name),
description: chaosText(device.description),
model: chaosText(device.model),
browser: chaosText(device.browser),
location: chaosText(device.location),
...chaosPick(chaosDevices, index),
description: device.isCurrent ? chaosPlace(index) : `Last seen September 28th, 2026 · ${chaosPlace(index)}`,
location: chaosPlace(index),
})),
);
const [devices, setDevices] = useState(seed);
Expand Down
Loading
Loading