From 66aa13c8eb67dd10392830932d40f93fe9f8465e Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 16:00:24 -0600 Subject: [PATCH 1/9] feat(mosaic): add ProviderLogo with lazily loaded provider logos Co-Authored-By: Claude Opus 5.5 --- .changeset/mosaic-provider-logo.md | 2 + .claude/skills/README.md | 10 +- .claude/skills/provider-logo-add/SKILL.md | 34 + .claude/skills/provider-logo-update/SKILL.md | 40 + .../references/logo-pipeline.md | 100 +++ packages/mosaic/package.json | 2 + .../scripts/generate-provider-logos.mjs | 230 ++++++ .../src/components/provider-logo/index.ts | 5 + .../provider-logo/logos/agentid.svg | 10 + .../components/provider-logo/logos/apple.svg | 4 + .../provider-logo/logos/atlassian.svg | 1 + .../components/provider-logo/logos/base.svg | 11 + .../provider-logo/logos/bitbucket.svg | 1 + .../components/provider-logo/logos/box.svg | 4 + .../provider-logo/logos/coinbase.svg | 4 + .../provider-logo/logos/coinbase_wallet.svg | 12 + .../provider-logo/logos/discord.svg | 4 + .../provider-logo/logos/dropbox.svg | 4 + .../provider-logo/logos/enstall.svg | 7 + .../provider-logo/logos/facebook.svg | 5 + .../components/provider-logo/logos/github.svg | 10 + .../components/provider-logo/logos/gitlab.svg | 10 + .../components/provider-logo/logos/google.svg | 14 + .../provider-logo/logos/hubspot.svg | 4 + .../provider-logo/logos/huggingface.svg | 16 + .../provider-logo/logos/instagram.svg | 1 + .../components/provider-logo/logos/line.svg | 9 + .../components/provider-logo/logos/linear.svg | 5 + .../provider-logo/logos/linkedin.svg | 4 + .../provider-logo/logos/linkedin_oidc.svg | 4 + .../provider-logo/logos/manifest.json | 292 ++++++++ .../provider-logo/logos/metamask.svg | 26 + .../provider-logo/logos/microsoft.svg | 1 + .../components/provider-logo/logos/notion.svg | 5 + .../components/provider-logo/logos/okta.svg | 4 + .../provider-logo/logos/okx_wallet.svg | 6 + .../components/provider-logo/logos/slack.svg | 11 + .../components/provider-logo/logos/solana.svg | 14 + .../provider-logo/logos/spotify.svg | 1 + .../components/provider-logo/logos/tiktok.svg | 3 + .../components/provider-logo/logos/twitch.svg | 7 + .../provider-logo/logos/twitter.svg | 11 + .../components/provider-logo/logos/vercel.svg | 4 + .../provider-logo/logos/whatsapp.svg | 12 + .../src/components/provider-logo/logos/x.svg | 11 + .../components/provider-logo/logos/xero.svg | 21 + .../provider-logo.glyphs.generated.tsx | 696 ++++++++++++++++++ .../provider-logo.ids.generated.ts | 41 ++ .../provider-logo/provider-logo.loader.ts | 45 ++ .../provider-logo/provider-logo.styles.ts | 8 + .../provider-logo/provider-logo.test.tsx | 63 ++ .../provider-logo/provider-logo.tsx | 45 ++ .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 + .../swingset/src/stories/provider-logo.mdx | 63 ++ .../src/stories/provider-logo.stories.tsx | 106 +++ pnpm-lock.yaml | 38 +- 57 files changed, 2109 insertions(+), 7 deletions(-) create mode 100644 .changeset/mosaic-provider-logo.md create mode 100644 .claude/skills/provider-logo-add/SKILL.md create mode 100644 .claude/skills/provider-logo-update/SKILL.md create mode 100644 .claude/skills/provider-logo-update/references/logo-pipeline.md create mode 100644 packages/mosaic/scripts/generate-provider-logos.mjs create mode 100644 packages/mosaic/src/components/provider-logo/index.ts create mode 100644 packages/mosaic/src/components/provider-logo/logos/agentid.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/apple.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/atlassian.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/base.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/bitbucket.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/box.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/coinbase.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/coinbase_wallet.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/discord.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/dropbox.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/enstall.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/facebook.svg create mode 100755 packages/mosaic/src/components/provider-logo/logos/github.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/gitlab.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/google.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/hubspot.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/huggingface.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/instagram.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/line.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/linear.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/linkedin.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/linkedin_oidc.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/manifest.json create mode 100644 packages/mosaic/src/components/provider-logo/logos/metamask.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/microsoft.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/notion.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/okta.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/okx_wallet.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/slack.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/solana.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/spotify.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/tiktok.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/twitch.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/twitter.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/vercel.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/whatsapp.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/x.svg create mode 100644 packages/mosaic/src/components/provider-logo/logos/xero.svg create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.glyphs.generated.tsx create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.ids.generated.ts create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.loader.ts create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.styles.ts create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.test.tsx create mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.tsx create mode 100644 packages/swingset/src/stories/provider-logo.mdx create mode 100644 packages/swingset/src/stories/provider-logo.stories.tsx diff --git a/.changeset/mosaic-provider-logo.md b/.changeset/mosaic-provider-logo.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-provider-logo.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 0031ac9bc9c..3255a21dbda 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -41,7 +41,9 @@ Skills are Claude Code specific. Cursor does not read this directory; it uses `. ## Skills in this repo -| Skill | Use it for | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | -| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| Skill | Use it for | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | +| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| `provider-logo-add` | Adding a logo for a new provider to Mosaic's `ProviderLogo`. | +| `provider-logo-update` | Checking provider logos against each brand's official assets and refreshing outdated ones. | diff --git a/.claude/skills/provider-logo-add/SKILL.md b/.claude/skills/provider-logo-add/SKILL.md new file mode 100644 index 00000000000..733deedb6ea --- /dev/null +++ b/.claude/skills/provider-logo-add/SKILL.md @@ -0,0 +1,34 @@ +--- +name: provider-logo-add +description: >- + Add a logo for a new OAuth, Web3, or phone-code provider to Mosaic's + `ProviderLogo` (the lazily loaded provider logo set). Use when a provider is + added to Clerk, or when asked to add a logo for a provider that + `ProviderLogo` does not support yet. To refresh an existing logo, use + `provider-logo-update`. +--- + +# Adding a provider logo + +Read `../provider-logo-update/references/logo-pipeline.md` first. It covers where the files +live, the manifest, the three treatments, sourcing rules, SVG cleanup, verification, and +changesets. + +## Workflow + +1. **Confirm the id.** It must match Clerk's provider id: `provider` in + `packages/shared/src/oauth.ts` or `packages/shared/src/web3.ts`, or `channel` in + `packages/shared/src/alternativePhoneCode.ts`. If the provider is not there yet, say so: adding + it to `@clerk/shared` is a separate change with its own changeset, and the backend has to + support it. Don't add it to shared as part of this skill unless asked. +2. **Find the brand page** and the official symbol SVG, per the sourcing rules. Read its guidance + for dark backgrounds and sign-in buttons. +3. **Pick a treatment** and explain the choice in one line to the user, citing the brand guidance. +4. **Add the files:** + - `packages/mosaic/src/components/provider-logo/logos/.svg`, cleaned per the reference. + - A manifest entry with `id`, `name`, `treatment`, `colors` if adaptive or recolored, `brand`, + `source` if anything is non-obvious, and `checked` set to today. Place it next to providers + from the same shared list, in that list's order. +5. **Generate and verify** per the reference. Report the gzip size change, since every app loads + the whole chunk. +6. **Changeset** per the reference. diff --git a/.claude/skills/provider-logo-update/SKILL.md b/.claude/skills/provider-logo-update/SKILL.md new file mode 100644 index 00000000000..c9927a5db2f --- /dev/null +++ b/.claude/skills/provider-logo-update/SKILL.md @@ -0,0 +1,40 @@ +--- +name: provider-logo-update +description: >- + Check Mosaic's provider logos (Google, GitHub, Apple, Discord, etc., used by + `ProviderLogo`) against each brand's official assets and update any that are + outdated, broken, or handled wrong in dark mode. Use when asked to refresh, + audit, or check for updated provider/OAuth/social logos, or to fix how one + provider's logo looks. For a provider that has no logo yet, use + `provider-logo-add`. +--- + +# Updating provider logos + +Read `references/logo-pipeline.md` first. It covers where the files live, the manifest, the +three treatments, sourcing rules, SVG cleanup, verification, and changesets. + +## Workflow + +1. **Scope.** One provider, a list, or all of them. For "all", sort the manifest by `checked` + (oldest first) and fan out the brand checks to parallel subagents, about 10 providers each. + Give each subagent the reference file and the current SVGs to compare against. +2. **Check each provider** against its `brand` URL and report, per provider: + - whether the current mark still matches the official one (shape and colors), + - what the brand says to do on dark backgrounds, + - the recommended treatment, and whether it differs from the manifest, + - the official SVG URL, or why one could not be fetched. +3. **Stop and report before changing anything.** Present a short table of proposed changes and + the providers that could not be verified. Logo choices are brand and product calls; wait for + the user to pick. +4. **Apply** the approved changes: replace `logos/.svg`, update `treatment`, `colors`, and + `source`, and set `checked` to today. Set `checked` on providers you verified as unchanged too. +5. **Verify** per the reference: generate, test, and look at both color schemes. +6. **Changeset** per the reference. + +## Judgment calls to surface, not decide + +- A brand has moved to a mark that is hard to reproduce as clean SVG (Google's 2025 gradient G). +- A brand rename (Twitter → X, Base App → Coinbase Wallet) where the old id stays. +- A color the brand's site uses but its asset files do not confirm (HubSpot). +- Spotify-style rules that turn a colored mark white on our dark background. diff --git a/.claude/skills/provider-logo-update/references/logo-pipeline.md b/.claude/skills/provider-logo-update/references/logo-pipeline.md new file mode 100644 index 00000000000..760f3cdd44c --- /dev/null +++ b/.claude/skills/provider-logo-update/references/logo-pipeline.md @@ -0,0 +1,100 @@ +# Provider logo pipeline + +Shared by `provider-logo-update` and `provider-logo-add`. + +## Where things live + +All paths are under `packages/mosaic/`. + +| Path | What | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `src/components/provider-logo/logos/.svg` | Source SVG per provider, as downloaded from the brand (hand-trimmed if needed) | +| `src/components/provider-logo/logos/manifest.json` | Source of truth: order, treatment, color swaps, brand URL, provenance, last check | +| `scripts/generate-provider-logos.mjs` | svgo + treatment + JSX codegen | +| `src/components/provider-logo/provider-logo.ids.generated.ts` | Generated id list and `ProviderLogoId` (main bundle) | +| `src/components/provider-logo/provider-logo.glyphs.generated.tsx` | Generated glyphs (the lazy chunk) | + +Never hand-edit a `*.generated.*` file. Edit the SVG or manifest, then run +`pnpm --filter @clerk/mosaic generate:provider-logos`. + +## Manifest entry + +```json +{ + "id": "atlassian", + "name": "Atlassian", + "treatment": "adaptive", + "colors": { "#1868db": "light-dark(#1868DB, #357DE8)" }, + "brand": "https://atlassian.design/foundations/logos", + "source": "Paths and colors from @atlaskit/logo.", + "checked": "2026-10-02" +} +``` + +- `id` is the Clerk provider id (`provider` in `packages/shared/src/oauth.ts`, `web3.ts`, or + `channel` in `alternativePhoneCode.ts`), and the SVG filename. +- `colors` maps a fill **as svgo writes it** (lowercase, shortened hex: `#000`, `#00f`) to its + replacement. The generator throws if a key is not found; its error prints the optimized markup. +- `brand` is the official brand or sign-in-button guideline page. Omit it only when none exists. +- `source` records anything non-obvious about where the asset came from or why it deviates. +- `checked` is the date the entry was last compared against the brand (ISO `YYYY-MM-DD`). + +## Choosing a treatment + +| Treatment | Use when | Effect | +| ---------- | ----------------------------------------------------------------------------------- | -------------------------------------- | +| `color` | A full-color mark the brand uses on both light and dark backgrounds | Rendered as-is | +| `mono` | The brand's mark is black/white only, or its login button uses a one-color mark | Every fill becomes `currentColor` | +| `adaptive` | The brand names different colors for dark backgrounds, or forbids its color on dark | Listed fills become `light-dark(a, b)` | + +Precedence: the brand's sign-in-button guideline beats its general logo page, which beats the +press kit. Apple must resolve to pure black or white (App Review checks custom buttons), so it is +`adaptive` with `light-dark(#000, #fff)`, not `mono`. + +The dark Mosaic background is `oklch(0.2046 0 0)`. If a `color` mark is near-invisible there and +the brand allows a white version, make it `adaptive`. + +## Sourcing an asset + +1. Open the manifest `brand` URL (WebFetch, or a browser for JS-rendered pages). Look for the + **symbol/icon mark**, not the wordmark, unless the brand has no symbol (Box). +2. Prefer a direct official SVG. Zips are common: download and unzip in the scratchpad. +3. Fallbacks, in order: the brand's own npm package (`@atlaskit/logo`), its developer docs' login + button kit, then [simple-icons](https://simpleicons.org) (CC0, one-color marks only). Record + the fallback in `source`. +4. Never redraw, recolor beyond the brand's own palette, or trace a raster. If only PNG exists, + stop and report it. + +Pages that commonly block fetching: X (402), Coinbase (403), Meta brand zips, Okta (partner +login), HubSpot (Brandfolder form), Notion (redirects to app login). Report these rather than +guessing. + +## Cleaning an SVG + +The generator throws on ``, ` + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/bitbucket.svg b/packages/mosaic/src/components/provider-logo/logos/bitbucket.svg new file mode 100644 index 00000000000..52251b0a77f --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/bitbucket.svg @@ -0,0 +1 @@ + diff --git a/packages/mosaic/src/components/provider-logo/logos/box.svg b/packages/mosaic/src/components/provider-logo/logos/box.svg new file mode 100644 index 00000000000..817c90e3dba --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/box.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/coinbase.svg b/packages/mosaic/src/components/provider-logo/logos/coinbase.svg new file mode 100644 index 00000000000..9b5f991b1ac --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/coinbase.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/coinbase_wallet.svg b/packages/mosaic/src/components/provider-logo/logos/coinbase_wallet.svg new file mode 100644 index 00000000000..db078a56e82 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/coinbase_wallet.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/discord.svg b/packages/mosaic/src/components/provider-logo/logos/discord.svg new file mode 100644 index 00000000000..4d92141a2cb --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/discord.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/dropbox.svg b/packages/mosaic/src/components/provider-logo/logos/dropbox.svg new file mode 100644 index 00000000000..a25ef22ed81 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/dropbox.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/enstall.svg b/packages/mosaic/src/components/provider-logo/logos/enstall.svg new file mode 100644 index 00000000000..4c9791b4fe4 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/enstall.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/facebook.svg b/packages/mosaic/src/components/provider-logo/logos/facebook.svg new file mode 100644 index 00000000000..4168f3eca8d --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/facebook.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/github.svg b/packages/mosaic/src/components/provider-logo/logos/github.svg new file mode 100755 index 00000000000..4f46bf35718 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/mosaic/src/components/provider-logo/logos/gitlab.svg b/packages/mosaic/src/components/provider-logo/logos/gitlab.svg new file mode 100644 index 00000000000..72f0884181a --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/gitlab.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/google.svg b/packages/mosaic/src/components/provider-logo/logos/google.svg new file mode 100644 index 00000000000..88ecef44f29 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/google.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/hubspot.svg b/packages/mosaic/src/components/provider-logo/logos/hubspot.svg new file mode 100644 index 00000000000..e432dccb5d0 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/hubspot.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/huggingface.svg b/packages/mosaic/src/components/provider-logo/logos/huggingface.svg new file mode 100644 index 00000000000..5f79ef338eb --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/huggingface.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/instagram.svg b/packages/mosaic/src/components/provider-logo/logos/instagram.svg new file mode 100644 index 00000000000..c0e86b0bfc7 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/instagram.svg @@ -0,0 +1 @@ +Instagram \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/line.svg b/packages/mosaic/src/components/provider-logo/logos/line.svg new file mode 100644 index 00000000000..c4386f2431a --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/line.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/linear.svg b/packages/mosaic/src/components/provider-logo/logos/linear.svg new file mode 100644 index 00000000000..e2484d70828 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/linear.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/mosaic/src/components/provider-logo/logos/linkedin.svg b/packages/mosaic/src/components/provider-logo/logos/linkedin.svg new file mode 100644 index 00000000000..18ced64e938 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/linkedin.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/linkedin_oidc.svg b/packages/mosaic/src/components/provider-logo/logos/linkedin_oidc.svg new file mode 100644 index 00000000000..5d0a00bda17 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/linkedin_oidc.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/manifest.json b/packages/mosaic/src/components/provider-logo/logos/manifest.json new file mode 100644 index 00000000000..15104cb6c00 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/manifest.json @@ -0,0 +1,292 @@ +{ + "providers": [ + { + "id": "google", + "name": "Google", + "treatment": "color", + "brand": "https://developers.google.com/identity/branding-guidelines", + "source": "Flat four-color G. The 2025 gradient G is not reproducible as clean SVG.", + "checked": "2026-10-02" + }, + { + "id": "discord", + "name": "Discord", + "treatment": "color", + "brand": "https://discord.com/branding", + "checked": "2026-10-02" + }, + { + "id": "facebook", + "name": "Facebook", + "treatment": "color", + "colors": { + "#1977f3": "#0866FF" + }, + "brand": "https://www.meta.com/brand/resources/facebook/logo/", + "checked": "2026-10-02" + }, + { + "id": "twitch", + "name": "Twitch", + "treatment": "color", + "brand": "https://brand.twitch.com", + "checked": "2026-10-02" + }, + { + "id": "twitter", + "name": "Twitter", + "treatment": "mono", + "brand": "https://about.x.com/en/who-we-are/brand-toolkit", + "source": "Renders the X mark; the bird is retired.", + "checked": "2026-10-02" + }, + { + "id": "microsoft", + "name": "Microsoft", + "treatment": "color", + "brand": "https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps", + "checked": "2026-10-02" + }, + { + "id": "tiktok", + "name": "TikTok", + "treatment": "mono", + "brand": "https://developers.tiktok.com/doc/getting-started-design-guidelines", + "source": "Note glyph from the login button pack.", + "checked": "2026-10-02" + }, + { + "id": "linkedin", + "name": "LinkedIn", + "treatment": "color", + "brand": "https://brand.linkedin.com/in-logo", + "checked": "2026-10-02" + }, + { + "id": "linkedin_oidc", + "name": "LinkedIn", + "treatment": "color", + "brand": "https://brand.linkedin.com/in-logo", + "checked": "2026-10-02" + }, + { + "id": "github", + "name": "GitHub", + "treatment": "mono", + "brand": "https://brand.github.com/foundations/logo", + "checked": "2026-10-02" + }, + { + "id": "gitlab", + "name": "GitLab", + "treatment": "color", + "brand": "https://design.gitlab.com/brand-logo/logomark", + "checked": "2026-10-02" + }, + { + "id": "dropbox", + "name": "Dropbox", + "treatment": "color", + "brand": "https://brand.dropbox.com/logo", + "checked": "2026-10-02" + }, + { + "id": "atlassian", + "name": "Atlassian", + "treatment": "adaptive", + "colors": { + "#1868db": "light-dark(#1868DB, #357DE8)" + }, + "brand": "https://atlassian.design/foundations/logos", + "source": "Paths and colors from @atlaskit/logo.", + "checked": "2026-10-02" + }, + { + "id": "bitbucket", + "name": "Bitbucket", + "treatment": "adaptive", + "colors": { + "#1868db": "light-dark(#1868DB, #357DE8)" + }, + "brand": "https://atlassian.design/foundations/logos", + "source": "Flat bucket from @atlaskit/logo.", + "checked": "2026-10-02" + }, + { + "id": "hubspot", + "name": "HubSpot", + "treatment": "color", + "brand": "https://www.hubspot.com/partners/technology/branding-guidelines", + "source": "Site now uses #FF4800; the gated asset files were not checked.", + "checked": "2026-10-02" + }, + { + "id": "notion", + "name": "Notion", + "treatment": "color", + "brand": "https://www.notion.com/brand", + "checked": "2026-10-02" + }, + { + "id": "apple", + "name": "Apple", + "treatment": "adaptive", + "colors": { + "#000": "light-dark(#000, #fff)" + }, + "brand": "https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple", + "source": "Apple requires pure black or white.", + "checked": "2026-10-02" + }, + { + "id": "line", + "name": "LINE", + "treatment": "color", + "brand": "https://developers.line.biz/en/docs/line-login/login-button/", + "checked": "2026-10-02" + }, + { + "id": "instagram", + "name": "Instagram", + "treatment": "mono", + "brand": "https://www.meta.com/brand/resources/instagram/", + "source": "Glyph from simple-icons (CC0).", + "checked": "2026-10-02" + }, + { + "id": "coinbase", + "name": "Coinbase", + "treatment": "color", + "colors": { + "#1652f0": "#0052FF" + }, + "brand": "https://www.coinbase.com/press", + "checked": "2026-10-02" + }, + { + "id": "spotify", + "name": "Spotify", + "treatment": "adaptive", + "colors": { + "#1ed760": "light-dark(#1ED760, #fff)" + }, + "brand": "https://developer.spotify.com/documentation/design", + "source": "Green only on pure black or white; otherwise monochrome.", + "checked": "2026-10-02" + }, + { + "id": "xero", + "name": "Xero", + "treatment": "color", + "brand": "https://developer.xero.com/documentation/xero-app-store/app-partner-guides/brand-guidelines", + "checked": "2026-10-02" + }, + { + "id": "box", + "name": "Box", + "treatment": "color", + "brand": "https://www.box.com/legal/trademark", + "checked": "2026-10-02" + }, + { + "id": "slack", + "name": "Slack", + "treatment": "color", + "brand": "https://slack.com/media-kit", + "checked": "2026-10-02" + }, + { + "id": "linear", + "name": "Linear", + "treatment": "mono", + "brand": "https://linear.app/brand", + "checked": "2026-10-02" + }, + { + "id": "x", + "name": "X", + "treatment": "mono", + "brand": "https://about.x.com/en/who-we-are/brand-toolkit", + "checked": "2026-10-02" + }, + { + "id": "enstall", + "name": "Enstall", + "treatment": "color", + "checked": "2026-10-02" + }, + { + "id": "huggingface", + "name": "Hugging Face", + "treatment": "color", + "brand": "https://huggingface.co/brand", + "checked": "2026-10-02" + }, + { + "id": "vercel", + "name": "Vercel", + "treatment": "mono", + "brand": "https://vercel.com/geist/brands", + "checked": "2026-10-02" + }, + { + "id": "agentid", + "name": "AgentID", + "treatment": "mono", + "brand": "https://agentid.com/brand", + "checked": "2026-10-02" + }, + { + "id": "metamask", + "name": "MetaMask", + "treatment": "color", + "brand": "https://metamask.io/assets", + "checked": "2026-10-02" + }, + { + "id": "base", + "name": "Base", + "treatment": "adaptive", + "colors": { + "#00f": "light-dark(#0000FF, #fff)" + }, + "brand": "https://brand.base.org", + "source": "Base Blue is not used on dark backgrounds.", + "checked": "2026-10-02" + }, + { + "id": "coinbase_wallet", + "name": "Coinbase Wallet", + "treatment": "color", + "source": "Renamed from Base App on 2026-09-08 with a new logo that has not been sourced yet.", + "checked": "2026-10-02" + }, + { + "id": "okx_wallet", + "name": "OKX Wallet", + "treatment": "mono", + "checked": "2026-10-02" + }, + { + "id": "solana", + "name": "Solana", + "treatment": "color", + "brand": "https://solana.com/branding", + "checked": "2026-10-02" + }, + { + "id": "whatsapp", + "name": "WhatsApp", + "treatment": "color", + "brand": "https://www.meta.com/brand/resources/whatsapp/", + "checked": "2026-10-02" + }, + { + "id": "okta", + "name": "Okta", + "treatment": "mono", + "brand": "https://www.okta.com/press-room/media-assets/", + "checked": "2026-10-02" + } + ] +} diff --git a/packages/mosaic/src/components/provider-logo/logos/metamask.svg b/packages/mosaic/src/components/provider-logo/logos/metamask.svg new file mode 100644 index 00000000000..66cbc401cfe --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/metamask.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/packages/mosaic/src/components/provider-logo/logos/microsoft.svg b/packages/mosaic/src/components/provider-logo/logos/microsoft.svg new file mode 100644 index 00000000000..1f739764834 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/microsoft.svg @@ -0,0 +1 @@ +MS-SymbolLockup \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/notion.svg b/packages/mosaic/src/components/provider-logo/logos/notion.svg new file mode 100644 index 00000000000..ce81b9599ba --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/notion.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/okta.svg b/packages/mosaic/src/components/provider-logo/logos/okta.svg new file mode 100644 index 00000000000..39dd047079c --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/okta.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/okx_wallet.svg b/packages/mosaic/src/components/provider-logo/logos/okx_wallet.svg new file mode 100644 index 00000000000..eea02667151 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/okx_wallet.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/slack.svg b/packages/mosaic/src/components/provider-logo/logos/slack.svg new file mode 100644 index 00000000000..a890a75e8ed --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/slack.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/solana.svg b/packages/mosaic/src/components/provider-logo/logos/solana.svg new file mode 100644 index 00000000000..08f2f998fab --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/solana.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/spotify.svg b/packages/mosaic/src/components/provider-logo/logos/spotify.svg new file mode 100644 index 00000000000..d3df92a200f --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/spotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/tiktok.svg b/packages/mosaic/src/components/provider-logo/logos/tiktok.svg new file mode 100644 index 00000000000..be4b482b6db --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/tiktok.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/src/components/provider-logo/logos/twitch.svg b/packages/mosaic/src/components/provider-logo/logos/twitch.svg new file mode 100644 index 00000000000..674e03aab83 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/twitch.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/twitter.svg b/packages/mosaic/src/components/provider-logo/logos/twitter.svg new file mode 100644 index 00000000000..eb25177fb40 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/twitter.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/vercel.svg b/packages/mosaic/src/components/provider-logo/logos/vercel.svg new file mode 100644 index 00000000000..2c6566a881e --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/vercel.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/whatsapp.svg b/packages/mosaic/src/components/provider-logo/logos/whatsapp.svg new file mode 100644 index 00000000000..e0b30d97691 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/whatsapp.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/x.svg b/packages/mosaic/src/components/provider-logo/logos/x.svg new file mode 100644 index 00000000000..eb25177fb40 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/x.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/logos/xero.svg b/packages/mosaic/src/components/provider-logo/logos/xero.svg new file mode 100644 index 00000000000..00b4ae799b2 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/xero.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.glyphs.generated.tsx b/packages/mosaic/src/components/provider-logo/provider-logo.glyphs.generated.tsx new file mode 100644 index 00000000000..37f104946c1 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.glyphs.generated.tsx @@ -0,0 +1,696 @@ +import type React from 'react'; + +import type { ProviderLogoId } from './provider-logo.ids.generated'; + +export type ProviderLogoGlyph = { viewBox: string; render: (uid: string) => React.ReactElement }; + +export const providerLogoGlyphs: Record = { + google: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + + + + ), + }, + discord: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + facebook: { + viewBox: '0 0 20 20', + render: () => ( + + + + + ), + }, + twitch: { + viewBox: '0 0 20 20', + render: () => ( + + + + + + ), + }, + twitter: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + ), + }, + microsoft: { + viewBox: '0 0 21 21', + render: () => ( + <> + + + + + + ), + }, + tiktok: { + viewBox: '10 9 26 26', + render: () => ( + + + + ), + }, + linkedin: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + linkedin_oidc: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + github: { + viewBox: '0 -1 98 98', + render: uid => ( + + + + + + + + + + + ), + }, + gitlab: { + viewBox: '0 0 20 20', + render: () => ( + + + + + + + + + ), + }, + dropbox: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + atlassian: { + viewBox: '4 2.5 24 24', + render: () => ( + + + + ), + }, + bitbucket: { + viewBox: '4 4 24 24', + render: () => ( + + + + ), + }, + hubspot: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + notion: { + viewBox: '0 0 20 20', + render: () => ( + + + + + ), + }, + apple: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + line: { + viewBox: '0 0 20 20', + render: () => ( + + + + + + ), + }, + instagram: { + viewBox: '0 0 24 24', + render: () => ( + + + + ), + }, + coinbase: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + spotify: { + viewBox: '0 -5.4 236.1 236.1', + render: () => ( + + ), + }, + xero: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + + + + + + + + ), + }, + box: { + viewBox: '0 0 20 20', + render: () => ( + + + + ), + }, + slack: { + viewBox: '0 0 20 20', + render: () => ( + + + + + + + ), + }, + linear: { + viewBox: '0 0 100 100', + render: () => ( + + + + ), + }, + x: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + ), + }, + enstall: { + viewBox: '0 0 192 192', + render: () => ( + <> + + + + + + ), + }, + huggingface: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + + + + + + ), + }, + vercel: { + viewBox: '0 -77.5 1155 1155', + render: () => ( + + + + ), + }, + agentid: { + viewBox: '0 0 20 20', + render: () => ( + + + + + + + ), + }, + metamask: { + viewBox: '0 -2.55 142 142', + render: () => ( + <> + + + + + + + + + ), + }, + base: { + viewBox: '0 0 1280 1280', + render: () => ( + + ), + }, + coinbase_wallet: { + viewBox: '0 0 20 20', + render: uid => ( + + + + + + + + + + + + ), + }, + okx_wallet: { + viewBox: '0 0 32 32', + render: () => ( + + + + ), + }, + solana: { + viewBox: '0 -1 20 20', + render: uid => ( + + + + + + + + + + + + + + ), + }, + whatsapp: { + viewBox: '-0.5 0 33 33', + render: uid => ( + + + + + + + + + + + + ), + }, + okta: { + viewBox: '0 0 24 24', + render: () => ( + + + + ), + }, +}; diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.ids.generated.ts b/packages/mosaic/src/components/provider-logo/provider-logo.ids.generated.ts new file mode 100644 index 00000000000..6409cab695b --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.ids.generated.ts @@ -0,0 +1,41 @@ +export const providerLogoIds = [ + 'google', + 'discord', + 'facebook', + 'twitch', + 'twitter', + 'microsoft', + 'tiktok', + 'linkedin', + 'linkedin_oidc', + 'github', + 'gitlab', + 'dropbox', + 'atlassian', + 'bitbucket', + 'hubspot', + 'notion', + 'apple', + 'line', + 'instagram', + 'coinbase', + 'spotify', + 'xero', + 'box', + 'slack', + 'linear', + 'x', + 'enstall', + 'huggingface', + 'vercel', + 'agentid', + 'metamask', + 'base', + 'coinbase_wallet', + 'okx_wallet', + 'solana', + 'whatsapp', + 'okta', +] as const; + +export type ProviderLogoId = (typeof providerLogoIds)[number]; diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.loader.ts b/packages/mosaic/src/components/provider-logo/provider-logo.loader.ts new file mode 100644 index 00000000000..dad5e05b39e --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.loader.ts @@ -0,0 +1,45 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +import type { ProviderLogoGlyph } from './provider-logo.glyphs.generated'; +import type { ProviderLogoId } from './provider-logo.ids.generated'; + +type Glyphs = Record; + +let glyphs: Glyphs | null = null; +let pending: Promise | null = null; +const listeners = new Set<() => void>(); + +export function preloadProviderLogos(): Promise { + pending ??= import('./provider-logo.glyphs.generated').then( + module => { + glyphs = module.providerLogoGlyphs; + listeners.forEach(listener => listener()); + }, + error => { + pending = null; + throw error; + }, + ); + return pending; +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useProviderLogoGlyphs(): Glyphs | null { + const current = useSyncExternalStore( + subscribe, + () => glyphs, + () => null, + ); + useEffect(() => { + if (!current) { + preloadProviderLogos().catch(() => {}); + } + }, [current]); + return current; +} diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.styles.ts b/packages/mosaic/src/components/provider-logo/provider-logo.styles.ts new file mode 100644 index 00000000000..0af39273f8b --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.styles.ts @@ -0,0 +1,8 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + base: { + display: 'inline-block', + flexShrink: 0, + }, +}); diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.test.tsx b/packages/mosaic/src/components/provider-logo/provider-logo.test.tsx new file mode 100644 index 00000000000..85b68d6ceec --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.test.tsx @@ -0,0 +1,63 @@ +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { isProviderLogoId, ProviderLogo } from './provider-logo'; +import { preloadProviderLogos } from './provider-logo.loader'; + +describe('Mosaic ProviderLogo', () => { + it('reserves its box and reflects its slot before the glyphs load', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + + expect(svg).toHaveClass('cl-provider-logo'); + expect(svg).toHaveAttribute('data-provider', 'google'); + expect(svg).toHaveAttribute('data-size', 'md'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders the glyph once the lazy module resolves', async () => { + const { container } = render(); + const svg = container.querySelector('svg')!; + + await waitFor(() => expect(svg).toHaveAttribute('viewBox')); + expect(svg.querySelector('path')).not.toBeNull(); + }); + + it('paints monochrome logos with currentColor', async () => { + await preloadProviderLogos(); + const { container } = render(); + + expect(container.querySelector('[fill="currentColor"]')).not.toBeNull(); + }); + + it('paints adaptive logos with light-dark colors', async () => { + await preloadProviderLogos(); + const { container } = render(); + + expect(container.querySelector('path')?.getAttribute('style')).toContain('light-dark'); + }); + + it('scopes internal ids to each instance', async () => { + await preloadProviderLogos(); + const { container } = render( + <> + + + , + ); + const ids = [...container.querySelectorAll('[id]')].map(node => node.id); + + expect(ids.length).toBeGreaterThan(1); + expect(new Set(ids).size).toBe(ids.length); + for (const node of container.querySelectorAll('[clip-path]')) { + const ref = node.getAttribute('clip-path')!.match(/#([^)]+)/)![1]; + expect(node.closest('svg')!.querySelector(`[id="${ref}"]`)).not.toBeNull(); + } + }); + + it('narrows provider strings', () => { + expect(isProviderLogoId('google')).toBe(true); + expect(isProviderLogoId('oauth_custom_acme')).toBe(false); + }); +}); diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.tsx b/packages/mosaic/src/components/provider-logo/provider-logo.tsx new file mode 100644 index 00000000000..158d0ea5cfc --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.tsx @@ -0,0 +1,45 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { sizes } from '../icon/icon.styles'; +import type { ProviderLogoId } from './provider-logo.ids.generated'; +import { providerLogoIds } from './provider-logo.ids.generated'; +import { useProviderLogoGlyphs } from './provider-logo.loader'; +import { styles } from './provider-logo.styles'; + +export interface ProviderLogoProps extends MosaicElementProps<'svg'> { + provider: ProviderLogoId; + size?: 'sm' | 'md' | 'lg' | 'inherit'; +} + +export function isProviderLogoId(value: string): value is ProviderLogoId { + return (providerLogoIds as readonly string[]).includes(value); +} + +export const ProviderLogo = React.forwardRef(function MosaicProviderLogo( + { provider, size = 'md', xstyle, ...rest }, + ref, +) { + const glyph = useProviderLogoGlyphs()?.[provider]; + const uid = React.useId().replace(/[^\w-]/g, ''); + const props = mergeStyleProps( + { 'aria-hidden': true }, + themeProps('provider-logo', { size, provider }), + stylex.props(reset.base, styles.base, sizes[size], xstyle), + rest, + ); + + return ( + + {glyph?.render(uid)} + + ); +}); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3031f104ab5..16e8c8a5c78 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -81,6 +81,7 @@ const docModules: Record> = { heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), + 'provider-logo': dynamic(() => import('../stories/provider-logo.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), otp: dynamic(() => import('../stories/otp.component.mdx')), panel: dynamic(() => import('../stories/panel.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3f604fdfd56..8ba620143e8 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -213,6 +213,12 @@ import { meta as profileComponentMeta, Transitions as ProfileTransitions, } from '../stories/profile.component.stories'; +import { + ColorSchemes as ProviderLogoColorSchemes, + Default as ProviderLogoDefault, + meta as providerLogoMeta, + Sizes as ProviderLogoSizes, +} from '../stories/provider-logo.stories'; import { AuthenticatorOTP as ReverificationAuthenticatorOTP, BackupCode as ReverificationBackupCode, @@ -672,6 +678,13 @@ const iconFrameModule: StoryModule = { BrandIcons: IconFrameBrandIcons, }; +const providerLogoModule: StoryModule = { + meta: providerLogoMeta, + Default: ProviderLogoDefault, + Sizes: ProviderLogoSizes, + ColorSchemes: ProviderLogoColorSchemes, +}; + // Headless primitives carry just `meta` (no story functions). Like every component // they're documented as a single overview page; their live demos come from `` / // `` embeds in the MDX, which import the stories module directly. @@ -964,6 +977,7 @@ export const registry: StoryModule[] = [ headingModule, iconModule, iconFrameModule, + providerLogoModule, menuComponentModule, otpComponentModule, paginationModule, diff --git a/packages/swingset/src/stories/provider-logo.mdx b/packages/swingset/src/stories/provider-logo.mdx new file mode 100644 index 00000000000..453db181277 --- /dev/null +++ b/packages/swingset/src/stories/provider-logo.mdx @@ -0,0 +1,63 @@ +import * as ProviderLogoStories from './provider-logo.stories'; + +# ProviderLogo + +ProviderLogo renders the logo for an OAuth, Web3, or phone-code provider. The logos load together in one lazy chunk on first render, and each logo adapts to the color scheme in scope: monochrome logos follow `currentColor`, and logos with distinct light and dark colors switch with `light-dark()`. + +## Playground + + + +## Props + +' }]} +/> + +## Usage + +```tsx +import { ProviderLogo } from '@clerk/mosaic/components/provider-logo'; + +; +``` + +`isProviderLogoId` narrows a provider string from the environment before rendering. A provider without a logo, such as a custom OAuth provider, renders its own image instead. + +```tsx +import { isProviderLogoId, ProviderLogo } from '@clerk/mosaic/components/provider-logo'; + +isProviderLogoId(provider) ? : ; +``` + +`preloadProviderLogos()` starts loading the chunk ahead of the first render. + +```tsx +import { preloadProviderLogos } from '@clerk/mosaic/components/provider-logo'; + +preloadProviderLogos(); +``` + +--- + +## Examples + +### Sizes + + + +`sm`, `md`, and `lg` match the `Icon` sizes; `inherit` sizes the logo to the surrounding font. + +### Color schemes + + diff --git a/packages/swingset/src/stories/provider-logo.stories.tsx b/packages/swingset/src/stories/provider-logo.stories.tsx new file mode 100644 index 00000000000..869270b4116 --- /dev/null +++ b/packages/swingset/src/stories/provider-logo.stories.tsx @@ -0,0 +1,106 @@ +import type { ProviderLogoProps } from '@clerk/mosaic/components/provider-logo'; +import { ProviderLogo, providerLogoIds } from '@clerk/mosaic/components/provider-logo'; +import { colorVars, space } from '@clerk/mosaic/tokens.stylex'; +import * as stylex from '@stylexjs/stylex'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './provider-logo.stories?raw'; + +const styles = stylex.create({ + row: { + display: 'flex', + alignItems: 'center', + gap: space['4'], + }, + schemes: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))', + gap: space['3'], + width: '100%', + }, + light: { + colorScheme: 'light', + }, + dark: { + colorScheme: 'dark', + }, + panel: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(2.5rem, 1fr))', + gap: space['4'], + justifyItems: 'center', + padding: space['4'], + borderRadius: space['2'], + backgroundColor: colorVars['--cl-color-background'], + color: colorVars['--cl-color-foreground'], + borderWidth: 1, + borderStyle: 'solid', + borderColor: colorVars['--cl-color-border'], + }, +}); + +export const meta: StoryMeta = { + group: 'Components', + status: 'wip', + title: 'ProviderLogo', + source: 'packages/mosaic/src/components/provider-logo/provider-logo.tsx', + styles: { + _variants: { + provider: Object.fromEntries(providerLogoIds.map(id => [id, {}])), + size: { sm: {}, md: {}, lg: {}, inherit: {} }, + }, + _defaultVariants: { + provider: 'google', + size: 'md', + }, + }, +}; + +function knobsAsProps(props: Record) { + return props as unknown as ProviderLogoProps; +} + +export function Default(props: Record) { + return ; +} + +export function Sizes() { + return ( +
+ + + +
+ ); +} + +export function ColorSchemes() { + return ( +
+ {(['light', 'dark'] as const).map(scheme => ( +
+ {providerLogoIds.map(id => ( + + ))} +
+ ))} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9c773b275d..e9b7b7b6a62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -856,6 +856,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + svgo: + specifier: ^3.3.5 + version: 3.3.5 tsdown: specifier: catalog:repo version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) @@ -8261,6 +8264,10 @@ packages: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -11268,6 +11275,9 @@ packages: mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -14040,6 +14050,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + svgo@3.3.5: + resolution: {integrity: sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==} + engines: {node: '>=14.0.0'} + hasBin: true + svgo@4.0.2: resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} engines: {node: '>=16'} @@ -22077,9 +22092,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@5.0.0) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -22131,7 +22146,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/utils@5.0.0': dependencies: @@ -23974,6 +23989,11 @@ snapshots: mdn-data: 2.0.28 source-map-js: 1.2.1 + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -27633,6 +27653,8 @@ snapshots: mdn-data@2.0.28: {} + mdn-data@2.0.30: {} + mdn-data@2.27.1: {} mdurl@2.0.0: {} @@ -31308,6 +31330,16 @@ snapshots: picocolors: 1.1.1 stable: 0.1.8 + svgo@3.3.5: + dependencies: + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + svgo@4.0.2: dependencies: commander: 11.1.0 From 1a2171394296e0804ba4451df1060c1559380f18 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 16:08:10 -0600 Subject: [PATCH 2/9] feat(mosaic): show the provider logo on fields managed by an enterprise connection Co-Authored-By: Claude Opus 5.5 --- ...ser-profile-account-section.model.test.tsx | 13 ++++++++++-- .../user-profile-name-row.view.test.tsx | 12 +++++++++++ .../user-profile-account-section.model.ts | 6 +++++- .../user-profile-managed-by.model.ts | 7 +++++++ .../user-profile/user-profile-managed-by.tsx | 20 ++++++++++++++----- ...ser-profile-password-section.model.test.ts | 14 ++++++++++--- .../user-profile-password-section.model.ts | 9 +++++++-- .../user-profile-password-section.tsx | 2 +- .../user-profile-account-section.stories.tsx | 5 +++-- .../stories/user-profile-password-section.mdx | 5 +++-- .../user-profile-password-section.stories.tsx | 2 +- 11 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-managed-by.model.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 5485c1364b7..04e097bdce9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -252,14 +252,23 @@ describe('useUserProfileAccountSectionModel', () => { enterpriseConnection: { name: 'Okta', logoPublicUrl: 'https://img.clerk.com/okta.svg' }, }); expect(ready()).toMatchObject({ - nameManagedBy: { name: 'Okta' }, + nameManagedBy: { name: 'Okta', provider: 'okta' }, onSubmitName: undefined, }); }); it('falls back to the provider when the active account carries no connection', () => { user?.enterpriseAccounts.push({ active: true, provider: 'saml_okta', enterpriseConnection: null }); - expect(ready().nameManagedBy).toEqual({ name: 'okta' }); + expect(ready().nameManagedBy).toEqual({ name: 'okta', provider: 'okta' }); + }); + + it('leaves the provider out for a connection without a bundled logo', () => { + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_custom', + enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: null }, + }); + expect(ready().nameManagedBy).toEqual({ name: 'Acme SSO' }); }); it('passes the instance name attributes through for the row to hide itself', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx index e932abb968d..4ded93d8a7b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -50,4 +50,16 @@ describe('UserProfileNameRowView', () => { expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it('shows the managing provider logo when one is bundled', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.cl-provider-logo')).toHaveAttribute('data-provider', 'okta'); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 9dd21959440..cb137fcee15 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -6,6 +6,7 @@ import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import type { MessageValues } from '../../../localization'; import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { toManagedByProvider } from '../user-profile-managed-by.model'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -50,7 +51,10 @@ function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfil return undefined; } const connection = account.enterpriseConnection; - return { name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, '') }; + return { + name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, ''), + provider: toManagedByProvider(account.provider), + }; } function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { diff --git a/packages/mosaic/src/features/user-profile/user-profile-managed-by.model.ts b/packages/mosaic/src/features/user-profile/user-profile-managed-by.model.ts new file mode 100644 index 00000000000..45026647af5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.model.ts @@ -0,0 +1,7 @@ +import type { ProviderLogoId } from '../../components/provider-logo'; +import { isProviderLogoId } from '../../components/provider-logo'; + +export function toManagedByProvider(provider: string | undefined): ProviderLogoId | undefined { + const id = provider?.replace(/^(oauth_|saml_)/, ''); + return id && isProviderLogoId(id) ? id : undefined; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx index 86e642ee459..6fb37a616c8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx @@ -1,22 +1,32 @@ import * as stylex from '@stylexjs/stylex'; import { Icon } from '../../components/icon'; +import type { ProviderLogoId } from '../../components/provider-logo'; +import { ProviderLogo } from '../../components/provider-logo'; import { Section, sectionCompactStyles } from '../../components/section'; /** The enterprise connection a row's value comes from, which is why the row has nothing to edit. */ export interface UserProfileManagedBy { name: string; + provider?: ProviderLogoId; } export function UserProfileManagedByLabel({ managedBy, label }: { managedBy: UserProfileManagedBy; label: string }) { return ( + managedBy.provider ? ( + + ) : ( + + ) } > {label} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index bca8f2fc05b..8736748ec4b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -9,7 +9,11 @@ import { useUserProfilePasswordModel } from './user-profile-password-section.mod type TestUser = { id: string; passwordEnabled: boolean; - enterpriseAccounts: { active: boolean; enterpriseConnection?: { name: string; logoPublicUrl: string | null } }[]; + enterpriseAccounts: { + active: boolean; + provider?: string; + enterpriseConnection?: { name: string; logoPublicUrl: string | null }; + }[]; updatePassword: ReturnType; }; @@ -168,13 +172,17 @@ describe('useUserProfilePasswordModel enterprise accounts', () => { } user.enterpriseAccounts = [ { active: false, enterpriseConnection: { name: 'Inactive', logoPublicUrl: null } }, - { active: true, enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: 'https://example.com/acme.png' } }, + { + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: 'https://example.com/acme.png' }, + }, ]; const { result } = renderHook(() => useUserProfilePasswordModel()); expect(result.current).toEqual({ status: 'readonly', mode: 'change', - managedBy: { name: 'Acme SSO' }, + managedBy: { name: 'Acme SSO', provider: 'okta' }, }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 94fb2ccf737..b2bcdeaa3a8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -7,8 +7,10 @@ import { useMemo } from 'react'; import type { FieldFeedback } from '../../../components/form'; import { FormSubmitError } from '../../../components/form'; +import type { ProviderLogoId } from '../../../components/provider-logo'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useErrorText, useLocale, useMessages } from '../../../localization'; +import { toManagedByProvider } from '../user-profile-managed-by.model'; import { passwordFormError } from './user-profile-password-errors'; import { passwordFieldFeedback } from './user-profile-password-feedback'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; @@ -22,7 +24,7 @@ type UnavailablePasswordModel = | { status: 'readonly'; mode: 'set' | 'change'; - managedBy: { name?: string }; + managedBy: { name?: string; provider?: ProviderLogoId }; }; export type UserProfilePasswordModel = @@ -59,7 +61,10 @@ function getPasswordPolicy( return { status: 'readonly', mode: policy.mode, - managedBy: { name: enterpriseAccount.enterpriseConnection?.name || undefined }, + managedBy: { + name: enterpriseAccount.enterpriseConnection?.name || undefined, + provider: toManagedByProvider(enterpriseAccount.provider), + }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index a89862fc1dd..5ce99b55887 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -34,7 +34,7 @@ export function useUserProfilePasswordSlot({ content: ( ), }; diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 54a81a99f38..464f48706d0 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,5 +1,6 @@ import { Button } from '@clerk/mosaic/components/button'; import type { + UserProfileAccountSectionViewProps, UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; @@ -49,7 +50,7 @@ function AccountSection({ failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; - nameManagedBy?: { name: string }; + nameManagedBy?: UserProfileAccountSectionViewProps['nameManagedBy']; }) { const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false); const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); @@ -132,7 +133,7 @@ export function NameManagedByConnection() { return ( ); } diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 471efaaaba4..582e7b8c01f 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -38,8 +38,9 @@ and translates save errors into form feedback. The view takes display data and a ## Managed by an enterprise connection -A lock icon leads the label; the provider's name comes from `managedBy.name`. Below `26rem` of -group width the label keeps only the name. +The provider's logo leads the label when `managedBy.provider` is set, and a lock icon otherwise; the +provider's name comes from `managedBy.name`. Below `26rem` of group width the label keeps only the +name. Date: Fri, 2 Oct 2026 17:04:07 -0600 Subject: [PATCH 3/9] feat(mosaic): use ProviderLogo in connected accounts and the SSO email verification dialog Co-Authored-By: Claude Opus 5.5 --- .../user-profile-verify-email-sso.dialog.test.tsx | 6 ++++++ .../user-profile-verify-email-sso.dialog.tsx | 8 +++++++- .../user-profile-connected-account-row.view.tsx | 9 +++++---- ...r-profile-connected-accounts-section.model.test.ts | 10 ++++------ .../user-profile-connected-accounts-section.model.ts | 11 ++++------- .../user-profile-connected-accounts-section.styles.ts | 10 ---------- .../user-profile-connected-accounts-section.types.ts | 4 +++- .../user-profile/user-profile-provider-icon.tsx | 11 +++++++++-- .../fixtures/user-profile-connected-accounts.ts | 7 ++++--- .../stories/fixtures/user-profile-verify-email-sso.ts | 2 +- ...ser-profile-connected-accounts-section.stories.tsx | 2 +- 11 files changed, 44 insertions(+), 36 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx index e652eefadba..13f22a688b6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx @@ -41,6 +41,12 @@ describe('UserProfileVerifyEmailSsoDialog', () => { expect(props.onOpenChange).not.toHaveBeenCalled(); }); + it('shows the bundled logo for a known provider', () => { + renderView({ connection: { provider: 'Okta SSO', domain: 'acme.co', logo: 'okta' } }); + + expect(document.querySelector('.cl-provider-logo')).toHaveAttribute('data-provider', 'okta'); + }); + it('prevents another connection attempt while connecting and still allows cancellation', async () => { const user = userEvent.setup(); const { props } = renderView({ isConnecting: true }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx index 6c5b9f21ee3..f82c0005d5a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx @@ -4,6 +4,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Icon } from '../../../components/icon'; import { Item } from '../../../components/item'; +import type { ProviderLogoId } from '../../../components/provider-logo'; import { Spinner } from '../../../components/spinner'; import { fill, useMessages } from '../../../localization'; import { UserProfileProviderIcon } from '../user-profile-provider-icon'; @@ -17,6 +18,7 @@ export interface UserProfileVerifyEmailSsoDialogProps { connection: { provider: string; domain: string; + logo?: ProviderLogoId; iconUrl?: string; }; onConnect: () => void; @@ -58,7 +60,11 @@ export function UserProfileVerifyEmailSsoDialog({ - {connection.iconUrl ? : null} + {connection.logo ? ( + + ) : connection.iconUrl ? ( + + ) : null} {connection.provider} {fill(m.connectionDescription, { domain: connection.domain })} diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx index e5dfb8bcd30..ba0633b6270 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx @@ -6,6 +6,7 @@ import { ActionMenu } from '../../../components/action-menu'; import { Badge } from '../../../components/badge'; import { SubmitButton } from '../../../components/button'; import { Icon, IconFrame } from '../../../components/icon'; +import { ProviderLogo } from '../../../components/provider-logo'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; import { styles } from './user-profile-connected-accounts-section.styles'; @@ -20,10 +21,10 @@ function ProviderMedia({ provider }: { provider: ConnectedAccountProviderDisplay return ( - {iconUrl && provider.monochromeIcon ? ( - ) : iconUrl ? ( { }); }); - it('marks monochrome provider logos', () => { + it('uses bundled logos for built-in providers', () => { const projection = projectConnectedAccounts({ user: userWith([]), providers: social('oauth_github', 'oauth_google'), socialEnabled: true, allowCreation: true, }); - expect( - projection.status === 'ready' && projection.availableProviders.map(p => [p.id, Boolean(p.monochromeIcon)]), - ).toEqual([ - ['oauth_github', true], - ['oauth_google', false], + expect(projection.status === 'ready' && projection.availableProviders.map(p => [p.id, p.logo, p.iconUrl])).toEqual([ + ['oauth_github', 'github', undefined], + ['oauth_google', 'google', undefined], ]); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index 60970f8f377..79a55fdf6ff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -16,6 +16,7 @@ import type { VerificationResource, } from '@clerk/shared/types'; +import { isProviderLogoId } from '../../../components/provider-logo'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { useErrorText, useMessages } from '../../../localization'; @@ -76,8 +77,6 @@ const RECONNECT_ERROR_CODES = [ 'external_account_email_address_verification_required', ]; -const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okx_wallet', 'vercel', 'x']; - type SocialSettings = Partial< Record & { strategy: string }> >; @@ -92,11 +91,9 @@ export function createProviderCatalog( strategy, provider, enabled: enabled.has(strategy), - display: { - provider: name, - iconUrl: iconImageUrl(provider), - monochromeIcon: MONOCHROME_PROVIDERS.includes(provider), - }, + display: isProviderLogoId(provider) + ? { provider: name, logo: provider } + : { provider: name, iconUrl: iconImageUrl(provider) }, })); const candidates = new Set([ ...enabledStrategies, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts index df13c6cd152..eadc1afbf17 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts @@ -5,16 +5,6 @@ import { colorVars, radiusVars, space } from '../../../tokens.stylex'; export const styles = stylex.create({ connectRow: { justifyContent: 'center' }, icon: { display: 'block', objectFit: 'contain', height: space['6'], width: space['6'] }, - maskedIcon: { - backgroundColor: 'currentColor', - display: 'block', - maskPosition: 'center', - maskRepeat: 'no-repeat', - maskSize: 'contain', - height: space['6'], - width: space['6'], - }, - maskImage: (url: string) => ({ maskImage: `url(${JSON.stringify(url)})` }), fallback: { borderRadius: radiusVars['--cl-radius-sm'], alignItems: 'center', diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts index 276150261f0..f6e411457f8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts @@ -1,9 +1,11 @@ +import type { ProviderLogoId } from '../../../components/provider-logo'; + export type ConnectedAccountActionResult = 'redirecting' | void; export interface ConnectedAccountProviderDisplay { provider: string; + logo?: ProviderLogoId; iconUrl?: string; - monochromeIcon?: boolean; } export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay { diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider-icon.tsx b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.tsx index f94148b6a0a..4beedeb1d94 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-provider-icon.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.tsx @@ -1,17 +1,24 @@ import * as stylex from '@stylexjs/stylex'; import { Icon, IconFrame } from '../../components/icon'; +import type { ProviderLogoId } from '../../components/provider-logo'; +import { ProviderLogo } from '../../components/provider-logo'; import { Section } from '../../components/section'; import type { IconName } from '../../icons/registry'; import { styles } from './user-profile-profile-panel.styles'; -type UserProfileProviderIconProps = { iconUrl: string; name?: never } | { iconUrl?: never; name: IconName }; +type UserProfileProviderIconProps = { iconUrl: string } | { name: IconName } | { logo: ProviderLogoId }; export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { return ( - {'iconUrl' in props ? ( + {'logo' in props ? ( + + ) : 'iconUrl' in props ? ( Date: Fri, 2 Oct 2026 17:16:54 -0600 Subject: [PATCH 4/9] refactor(mosaic): bundle provider logos statically instead of lazy loading Co-Authored-By: Claude Opus 5.5 --- .claude/skills/provider-logo-add/SKILL.md | 6 +-- .../references/logo-pipeline.md | 31 +++++++++++-- .../src/components/provider-logo/index.ts | 1 - .../provider-logo/provider-logo.loader.ts | 45 ------------------- .../provider-logo/provider-logo.test.tsx | 24 +++------- .../provider-logo/provider-logo.tsx | 8 ++-- .../swingset/src/stories/provider-logo.mdx | 10 +---- 7 files changed, 42 insertions(+), 83 deletions(-) delete mode 100644 packages/mosaic/src/components/provider-logo/provider-logo.loader.ts diff --git a/.claude/skills/provider-logo-add/SKILL.md b/.claude/skills/provider-logo-add/SKILL.md index 733deedb6ea..c7974a84f8b 100644 --- a/.claude/skills/provider-logo-add/SKILL.md +++ b/.claude/skills/provider-logo-add/SKILL.md @@ -2,7 +2,7 @@ name: provider-logo-add description: >- Add a logo for a new OAuth, Web3, or phone-code provider to Mosaic's - `ProviderLogo` (the lazily loaded provider logo set). Use when a provider is + `ProviderLogo` (the bundled provider logo set). Use when a provider is added to Clerk, or when asked to add a logo for a provider that `ProviderLogo` does not support yet. To refresh an existing logo, use `provider-logo-update`. @@ -29,6 +29,6 @@ changesets. - A manifest entry with `id`, `name`, `treatment`, `colors` if adaptive or recolored, `brand`, `source` if anything is non-obvious, and `checked` set to today. Place it next to providers from the same shared list, in that list's order. -5. **Generate and verify** per the reference. Report the gzip size change, since every app loads - the whole chunk. +5. **Generate and verify** per the reference. Report the gzip size change, since every bundle that + imports `ProviderLogo` carries every logo. 6. **Changeset** per the reference. diff --git a/.claude/skills/provider-logo-update/references/logo-pipeline.md b/.claude/skills/provider-logo-update/references/logo-pipeline.md index 760f3cdd44c..5f3b26294b1 100644 --- a/.claude/skills/provider-logo-update/references/logo-pipeline.md +++ b/.claude/skills/provider-logo-update/references/logo-pipeline.md @@ -11,8 +11,8 @@ All paths are under `packages/mosaic/`. | `src/components/provider-logo/logos/.svg` | Source SVG per provider, as downloaded from the brand (hand-trimmed if needed) | | `src/components/provider-logo/logos/manifest.json` | Source of truth: order, treatment, color swaps, brand URL, provenance, last check | | `scripts/generate-provider-logos.mjs` | svgo + treatment + JSX codegen | -| `src/components/provider-logo/provider-logo.ids.generated.ts` | Generated id list and `ProviderLogoId` (main bundle) | -| `src/components/provider-logo/provider-logo.glyphs.generated.tsx` | Generated glyphs (the lazy chunk) | +| `src/components/provider-logo/provider-logo.ids.generated.ts` | Generated id list and `ProviderLogoId` | +| `src/components/provider-logo/provider-logo.glyphs.generated.tsx` | Generated glyphs, imported statically by `ProviderLogo` | Never hand-edit a `*.generated.*` file. Edit the SVG or manifest, then run `pnpm --filter @clerk/mosaic generate:provider-logos`. @@ -84,14 +84,37 @@ The generator throws on ``, `