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 5b90de9df83..441fa4f631e 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -45,8 +45,10 @@ relevant. ## 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). | -| `verify-clerk-expo` | Proving a `@clerk/expo` change on an iOS simulator or Android emulator, local or borrowed on a CI runner, with video and screenshots as evidence. | +| 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). | +| `verify-clerk-expo` | Proving a `@clerk/expo` change on an iOS simulator or Android emulator, local or borrowed on a CI runner, with video and screenshots as evidence. | +| `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..480d01b8c12 --- /dev/null +++ b/.claude/skills/provider-logo-add/SKILL.md @@ -0,0 +1,35 @@ +--- +name: provider-logo-add +description: >- + Add a logo for a new OAuth, Web3, or phone-code provider to Mosaic's + `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`. +--- + +# 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`, `groups` (every group whose lookup should + resolve it, keyed by that group's provider ids), `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 bundle that + imports one of the logo's groups carries it. +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..6335381249e --- /dev/null +++ b/.claude/skills/provider-logo-update/references/logo-pipeline.md @@ -0,0 +1,117 @@ +# 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/glyphs/.generated.tsx` | Generated glyph module per logo | +| `src/components/provider-logo/.generated.ts` | Generated map and lookup per group (`oauth`, `enterprise`, `web3`, `phone`) | + +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", + "groups": { "oauth": ["atlassian"] }, + "colors": { "#1868db": "light-dark(#1868DB, #357DE8)" }, + "brand": "https://atlassian.design/foundations/logos", + "source": "Paths and colors from @atlaskit/logo.", + "checked": "2026-10-02" +} +``` + +- `id` names the logo: the SVG filename and the glyph module. Use the Clerk provider id when the + logo belongs to one (`provider` in `packages/shared/src/oauth.ts` or `web3.ts`, `channel` in + `alternativePhoneCode.ts`). +- `groups` lists, per group, the ids a lookup in that group resolves to this logo. Keys are the ids + Clerk uses in that context: OAuth and Web3 providers, phone code channels, and full enterprise + provider ids (`saml_okta`, `oauth_google`, `oidc_gitlab`). A logo in several groups (Google is + `oauth` and `enterprise`) is one module that each group map references. +- `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..8d1db77e855 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/logos/manifest.json @@ -0,0 +1,407 @@ +{ + "providers": [ + { + "id": "google", + "name": "Google", + "treatment": "color", + "groups": { + "oauth": ["google"], + "enterprise": ["saml_google", "oauth_google"] + }, + "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", + "groups": { + "oauth": ["discord"] + }, + "brand": "https://discord.com/branding", + "checked": "2026-10-02" + }, + { + "id": "facebook", + "name": "Facebook", + "treatment": "color", + "groups": { + "oauth": ["facebook"] + }, + "colors": { + "#1977f3": "#0866FF" + }, + "brand": "https://www.meta.com/brand/resources/facebook/logo/", + "checked": "2026-10-02" + }, + { + "id": "twitch", + "name": "Twitch", + "treatment": "color", + "groups": { + "oauth": ["twitch"] + }, + "brand": "https://brand.twitch.com", + "checked": "2026-10-02" + }, + { + "id": "twitter", + "name": "Twitter", + "treatment": "mono", + "groups": { + "oauth": ["twitter"] + }, + "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", + "groups": { + "oauth": ["microsoft"], + "enterprise": ["saml_microsoft", "oauth_microsoft"] + }, + "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", + "groups": { + "oauth": ["tiktok"] + }, + "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", + "groups": { + "oauth": ["linkedin"] + }, + "brand": "https://brand.linkedin.com/in-logo", + "checked": "2026-10-02" + }, + { + "id": "linkedin_oidc", + "name": "LinkedIn", + "treatment": "color", + "groups": { + "oauth": ["linkedin_oidc"] + }, + "brand": "https://brand.linkedin.com/in-logo", + "checked": "2026-10-02" + }, + { + "id": "github", + "name": "GitHub", + "treatment": "mono", + "groups": { + "oauth": ["github"], + "enterprise": ["oidc_github_enterprise"] + }, + "brand": "https://brand.github.com/foundations/logo", + "checked": "2026-10-02" + }, + { + "id": "gitlab", + "name": "GitLab", + "treatment": "color", + "groups": { + "oauth": ["gitlab"], + "enterprise": ["oidc_gitlab"] + }, + "brand": "https://design.gitlab.com/brand-logo/logomark", + "checked": "2026-10-02" + }, + { + "id": "dropbox", + "name": "Dropbox", + "treatment": "color", + "groups": { + "oauth": ["dropbox"] + }, + "brand": "https://brand.dropbox.com/logo", + "checked": "2026-10-02" + }, + { + "id": "atlassian", + "name": "Atlassian", + "treatment": "adaptive", + "groups": { + "oauth": ["atlassian"] + }, + "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", + "groups": { + "oauth": ["bitbucket"] + }, + "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", + "groups": { + "oauth": ["hubspot"] + }, + "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", + "groups": { + "oauth": ["notion"] + }, + "brand": "https://www.notion.com/brand", + "checked": "2026-10-02" + }, + { + "id": "apple", + "name": "Apple", + "treatment": "adaptive", + "groups": { + "oauth": ["apple"] + }, + "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", + "groups": { + "oauth": ["line"] + }, + "brand": "https://developers.line.biz/en/docs/line-login/login-button/", + "checked": "2026-10-02" + }, + { + "id": "instagram", + "name": "Instagram", + "treatment": "mono", + "groups": { + "oauth": ["instagram"] + }, + "brand": "https://www.meta.com/brand/resources/instagram/", + "source": "Glyph from simple-icons (CC0).", + "checked": "2026-10-02" + }, + { + "id": "coinbase", + "name": "Coinbase", + "treatment": "color", + "groups": { + "oauth": ["coinbase"] + }, + "colors": { + "#1652f0": "#0052FF" + }, + "brand": "https://www.coinbase.com/press", + "checked": "2026-10-02" + }, + { + "id": "spotify", + "name": "Spotify", + "treatment": "adaptive", + "groups": { + "oauth": ["spotify"] + }, + "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", + "groups": { + "oauth": ["xero"] + }, + "brand": "https://developer.xero.com/documentation/xero-app-store/app-partner-guides/brand-guidelines", + "checked": "2026-10-02" + }, + { + "id": "box", + "name": "Box", + "treatment": "color", + "groups": { + "oauth": ["box"] + }, + "brand": "https://www.box.com/legal/trademark", + "checked": "2026-10-02" + }, + { + "id": "slack", + "name": "Slack", + "treatment": "color", + "groups": { + "oauth": ["slack"] + }, + "brand": "https://slack.com/media-kit", + "checked": "2026-10-02" + }, + { + "id": "linear", + "name": "Linear", + "treatment": "mono", + "groups": { + "oauth": ["linear"] + }, + "brand": "https://linear.app/brand", + "checked": "2026-10-02" + }, + { + "id": "x", + "name": "X", + "treatment": "mono", + "groups": { + "oauth": ["x"] + }, + "brand": "https://about.x.com/en/who-we-are/brand-toolkit", + "checked": "2026-10-02" + }, + { + "id": "enstall", + "name": "Enstall", + "treatment": "color", + "groups": { + "oauth": ["enstall"] + }, + "checked": "2026-10-02" + }, + { + "id": "huggingface", + "name": "Hugging Face", + "treatment": "color", + "groups": { + "oauth": ["huggingface"] + }, + "brand": "https://huggingface.co/brand", + "checked": "2026-10-02" + }, + { + "id": "vercel", + "name": "Vercel", + "treatment": "mono", + "groups": { + "oauth": ["vercel"] + }, + "brand": "https://vercel.com/geist/brands", + "checked": "2026-10-02" + }, + { + "id": "agentid", + "name": "AgentID", + "treatment": "mono", + "groups": { + "oauth": ["agentid"] + }, + "brand": "https://agentid.com/brand", + "checked": "2026-10-02" + }, + { + "id": "metamask", + "name": "MetaMask", + "treatment": "color", + "groups": { + "web3": ["metamask"] + }, + "brand": "https://metamask.io/assets", + "checked": "2026-10-02" + }, + { + "id": "base", + "name": "Base", + "treatment": "adaptive", + "groups": { + "web3": ["base"] + }, + "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", + "groups": { + "web3": ["coinbase_wallet"] + }, + "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", + "groups": { + "web3": ["okx_wallet"] + }, + "checked": "2026-10-02" + }, + { + "id": "solana", + "name": "Solana", + "treatment": "color", + "groups": { + "web3": ["solana"] + }, + "brand": "https://solana.com/branding", + "checked": "2026-10-02" + }, + { + "id": "whatsapp", + "name": "WhatsApp", + "treatment": "color", + "groups": { + "phone": ["whatsapp"] + }, + "brand": "https://www.meta.com/brand/resources/whatsapp/", + "checked": "2026-10-02" + }, + { + "id": "okta", + "name": "Okta", + "treatment": "mono", + "groups": { + "enterprise": ["saml_okta"] + }, + "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/oauth.generated.ts b/packages/mosaic/src/components/provider-logo/oauth.generated.ts new file mode 100644 index 00000000000..faa7a99a337 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/oauth.generated.ts @@ -0,0 +1,72 @@ +import { agentid } from './glyphs/agentid.generated'; +import { apple } from './glyphs/apple.generated'; +import { atlassian } from './glyphs/atlassian.generated'; +import { bitbucket } from './glyphs/bitbucket.generated'; +import { box } from './glyphs/box.generated'; +import { coinbase } from './glyphs/coinbase.generated'; +import { discord } from './glyphs/discord.generated'; +import { dropbox } from './glyphs/dropbox.generated'; +import { enstall } from './glyphs/enstall.generated'; +import { facebook } from './glyphs/facebook.generated'; +import { github } from './glyphs/github.generated'; +import { gitlab } from './glyphs/gitlab.generated'; +import { google } from './glyphs/google.generated'; +import { hubspot } from './glyphs/hubspot.generated'; +import { huggingface } from './glyphs/huggingface.generated'; +import { instagram } from './glyphs/instagram.generated'; +import { line } from './glyphs/line.generated'; +import { linear } from './glyphs/linear.generated'; +import { linkedin } from './glyphs/linkedin.generated'; +import { linkedinOidc } from './glyphs/linkedin_oidc.generated'; +import { microsoft } from './glyphs/microsoft.generated'; +import { notion } from './glyphs/notion.generated'; +import { slack } from './glyphs/slack.generated'; +import { spotify } from './glyphs/spotify.generated'; +import { tiktok } from './glyphs/tiktok.generated'; +import { twitch } from './glyphs/twitch.generated'; +import { twitter } from './glyphs/twitter.generated'; +import { vercel } from './glyphs/vercel.generated'; +import { x } from './glyphs/x.generated'; +import { xero } from './glyphs/xero.generated'; +import type { ProviderLogoGlyph } from './provider-logo.types'; + +export const oauthLogos = { + google, + discord, + facebook, + twitch, + twitter, + microsoft, + tiktok, + linkedin, + linkedin_oidc: linkedinOidc, + github, + gitlab, + dropbox, + atlassian, + bitbucket, + hubspot, + notion, + apple, + line, + instagram, + coinbase, + spotify, + xero, + box, + slack, + linear, + x, + enstall, + huggingface, + vercel, + agentid, +} satisfies Record; + +export type OAuthLogoId = keyof typeof oauthLogos; + +const lookup: ReadonlyMap = new Map(Object.entries(oauthLogos)); + +export function getOAuthLogo(id: string): ProviderLogoGlyph | undefined { + return lookup.get(id); +} diff --git a/packages/mosaic/src/components/provider-logo/phone.generated.ts b/packages/mosaic/src/components/provider-logo/phone.generated.ts new file mode 100644 index 00000000000..e16428dcbee --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/phone.generated.ts @@ -0,0 +1,14 @@ +import { whatsapp } from './glyphs/whatsapp.generated'; +import type { ProviderLogoGlyph } from './provider-logo.types'; + +export const phoneLogos = { + whatsapp, +} satisfies Record; + +export type PhoneLogoId = keyof typeof phoneLogos; + +const lookup: ReadonlyMap = new Map(Object.entries(phoneLogos)); + +export function getPhoneLogo(id: string): ProviderLogoGlyph | undefined { + return lookup.get(id); +} 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..4a6566a5bc8 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.test.tsx @@ -0,0 +1,76 @@ +import { ALTERNATIVE_PHONE_CODE_PROVIDERS } from '@clerk/shared/alternativePhoneCode'; +import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; +import { WEB3_PROVIDERS } from '@clerk/shared/web3'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { enterpriseLogos, getEnterpriseLogo } from './enterprise.generated'; +import { getOAuthLogo, oauthLogos } from './oauth.generated'; +import { phoneLogos } from './phone.generated'; +import { ProviderLogo } from './provider-logo'; +import { web3Logos } from './web3.generated'; + +describe('Mosaic ProviderLogo', () => { + it('renders the glyph and reflects its slot', () => { + 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'); + expect(svg).toHaveAttribute('viewBox'); + expect(svg?.querySelector('path')).not.toBeNull(); + }); + + it('paints monochrome logos with currentColor', () => { + const { container } = render(); + + expect(container.querySelector('[fill="currentColor"]')).not.toBeNull(); + }); + + it('paints adaptive logos with light-dark colors', () => { + const { container } = render(); + + expect(container.querySelector('path')?.getAttribute('style')).toContain('light-dark'); + }); + + it('scopes internal ids to each instance', () => { + 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(ref && node.closest('svg')?.querySelector(`[id="${ref}"]`)).toBeTruthy(); + } + }); +}); + +describe('provider logo groups', () => { + it('covers every OAuth, Web3, and phone provider Clerk supports', () => { + expect(Object.keys(oauthLogos).sort()).toEqual(OAUTH_PROVIDERS.map(p => p.provider).sort()); + expect(Object.keys(web3Logos).sort()).toEqual(WEB3_PROVIDERS.map(p => p.provider).sort()); + expect(Object.keys(phoneLogos).sort()).toEqual(ALTERNATIVE_PHONE_CODE_PROVIDERS.map(p => p.channel).sort()); + }); + + it('shares one glyph between groups', () => { + expect(enterpriseLogos.saml_google).toBe(oauthLogos.google); + expect(enterpriseLogos.oauth_microsoft).toBe(oauthLogos.microsoft); + }); + + it('looks up ids only within their own group', () => { + expect(getEnterpriseLogo('saml_okta')?.id).toBe('okta'); + expect(getEnterpriseLogo('okta')).toBeUndefined(); + expect(getEnterpriseLogo('google')).toBeUndefined(); + expect(getOAuthLogo('saml_google')).toBeUndefined(); + expect(getOAuthLogo('custom_acme')).toBeUndefined(); + }); +}); 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..a2d95f28ac8 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.tsx @@ -0,0 +1,39 @@ +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 type { IconProps } from '../icon'; +import { sizes } from '../icon/icon.styles'; +import { styles } from './provider-logo.styles'; +import type { ProviderLogoGlyph } from './provider-logo.types'; + +export interface ProviderLogoProps extends MosaicElementProps<'svg'> { + glyph: ProviderLogoGlyph; + size?: IconProps['size']; +} + +export const ProviderLogo = React.forwardRef(function MosaicProviderLogo( + { glyph, size = 'md', xstyle, ...rest }, + ref, +) { + const uid = React.useId().replace(/[^\w-]/g, ''); + const props = mergeStyleProps( + { 'aria-hidden': true }, + themeProps('provider-logo', { size, provider: glyph.id }), + stylex.props(reset.base, styles.base, sizes[size], xstyle), + rest, + ); + + return ( + + {glyph.render(uid)} + + ); +}); diff --git a/packages/mosaic/src/components/provider-logo/provider-logo.types.ts b/packages/mosaic/src/components/provider-logo/provider-logo.types.ts new file mode 100644 index 00000000000..0bf4147ff91 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/provider-logo.types.ts @@ -0,0 +1,7 @@ +import type React from 'react'; + +export type ProviderLogoGlyph = { + id: string; + viewBox: string; + render: (uid: string) => React.ReactElement; +}; diff --git a/packages/mosaic/src/components/provider-logo/web3.generated.ts b/packages/mosaic/src/components/provider-logo/web3.generated.ts new file mode 100644 index 00000000000..1b59fef5723 --- /dev/null +++ b/packages/mosaic/src/components/provider-logo/web3.generated.ts @@ -0,0 +1,22 @@ +import { base } from './glyphs/base.generated'; +import { coinbaseWallet } from './glyphs/coinbase_wallet.generated'; +import { metamask } from './glyphs/metamask.generated'; +import { okxWallet } from './glyphs/okx_wallet.generated'; +import { solana } from './glyphs/solana.generated'; +import type { ProviderLogoGlyph } from './provider-logo.types'; + +export const web3Logos = { + metamask, + base, + coinbase_wallet: coinbaseWallet, + okx_wallet: okxWallet, + solana, +} satisfies Record; + +export type Web3LogoId = keyof typeof web3Logos; + +const lookup: ReadonlyMap = new Map(Object.entries(web3Logos)); + +export function getWeb3Logo(id: string): ProviderLogoGlyph | undefined { + return lookup.get(id); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 11026dd9f18..862aaf2acc5 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -107,7 +107,13 @@ describe('UserProfileProfilePanelView', () => { null} wallets={[ - { id: 'metamask', provider: 'MetaMask', address: 'test', isVerified: true, iconUrl: '/metamask.svg' }, + { + id: 'metamask', + provider: 'MetaMask', + address: 'test', + isVerified: true, + icon: { iconUrl: '/metamask.svg' }, + }, ]} /> ), @@ -131,7 +137,7 @@ describe('UserProfileProfilePanelView', () => { id: 'primary', address: '0x1234567890abcdef1234567890abcdef12345678', provider: 'MetaMask', - iconUrl: 'https://example.com/metamask.svg', + icon: { iconUrl: 'https://example.com/metamask.svg' }, isPrimary: true, isVerified: true, }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts index 543c585adb5..e7ce7297f89 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts @@ -1,6 +1,7 @@ import type { OAuthProvider } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; +import { oauthLogos } from '../../../../components/provider-logo/oauth.generated'; import { createProviderCatalog, projectConnectedAccounts, @@ -187,22 +188,20 @@ describe('projectConnectedAccounts', () => { }); expect(projection.status === 'ready' && projection.accounts[0]).toMatchObject({ provider: 'Custom Provider', - iconUrl: 'https://img.example/custom.png', + icon: { iconUrl: 'https://img.example/custom.png' }, }); }); - 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.icon])).toEqual([ + ['oauth_github', { logo: oauthLogos.github }], + ['oauth_google', { logo: oauthLogos.google }], ]); }); 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 9e2f5df2392..c5f5e450c39 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 @@ -10,7 +10,7 @@ import { Section } from '../../../components/section'; import { ArrowUpRight } from '../../../icons/glyphs/arrow-up-right'; import { fill, useMessages } from '../../../localization'; import { truncationStyles } from '../../../styles/typography.styles'; -import { UserProfileProviderMedia } from '../user-profile-provider-media'; +import { UserProfileProviderIcon } from '../user-profile-provider-icon'; import { styles } from './user-profile-connected-accounts-section.styles'; import type { UserProfileConnectedAccount, @@ -34,11 +34,7 @@ export function UserProfileConnectProviderRowView({ return ( - + - + provider.provider === account.provider)?.display ?? { provider: account.provider }), + ...(providers.find(provider => provider.provider === account.provider)?.display ?? { + provider: account.provider, + icon: toProviderIcon({ logo: getOAuthLogo(account.provider), label: account.provider }), + }), identifier: account.username || account.emailAddress || undefined, status, verificationError: status === 'error' ? error?.longMessage : undefined, 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 77168d0dfd5..08532634ede 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 @@ -3,13 +3,13 @@ import type { Ref } from 'react'; import type { ConfirmController } from '../../../blocks/confirm'; import type { DialogFocusTarget } from '../../../components/dialog'; import type { ReverificationController } from '../../reverification'; +import type { UserProfileProviderIconProps } from '../user-profile-provider-icon'; export type ConnectedAccountActionResult = 'redirecting' | void; export interface ConnectedAccountProviderDisplay { provider: string; - iconUrl?: string; - monochromeIcon?: boolean; + icon: UserProfileProviderIconProps; } export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay { diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-projection.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-projection.test.ts index 7d7b24fc198..723b90be652 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-projection.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-projection.test.ts @@ -6,12 +6,14 @@ describe('projectEnterpriseAccounts', () => { const connection = (id: string, allowOrganizationAccountLinking = true) => ({ id, name: id, + provider: 'saml_custom', allowOrganizationAccountLinking, }); const account = (id: string, enterpriseConnectionId: string, active = true, error?: string) => ({ id, emailAddress: `${id}@example.com`, enterpriseConnectionId, + provider: 'saml_custom' as const, verification: error ? { error: { longMessage: error } } : null, enterpriseConnection: { active, name: enterpriseConnectionId, logoPublicUrl: null }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx index 63929b3dabb..3a3b2cc8173 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -2,11 +2,12 @@ import * as stylex from '@stylexjs/stylex'; import { Badge } from '../../../components/badge'; import { SubmitButton } from '../../../components/button'; -import { Icon, IconFrame } from '../../../components/icon'; +import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; import { ArrowUpRight } from '../../../icons/glyphs/arrow-up-right'; import { fill, useMessages } from '../../../localization'; import { truncationStyles } from '../../../styles/typography.styles'; +import { UserProfileProviderIcon } from '../user-profile-provider-icon'; import { styles } from './user-profile-enterprise-accounts-section.styles'; import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-accounts-section.types'; @@ -22,29 +23,10 @@ export function UserProfileEnterpriseAccountRowView({ disabled?: boolean; }) { const m = useMessages('userProfileEnterpriseAccountsSection'); - const iconUrl = account.iconUrl?.trim(); return ( - - - {iconUrl ? ( - - ) : ( - - {account.name.trim().charAt(0).toUpperCase()} - - )} - - + & { +type Account = Pick & { verification?: { error?: { longMessage?: string | null } | null } | null; enterpriseConnection: { active: boolean; @@ -23,7 +25,7 @@ type Account = Pick & { +type Connection = Pick & { logoPublicUrl?: string | null; }; @@ -50,13 +52,25 @@ export function projectEnterpriseAccounts({ .map(account => ({ id: account.id ?? '', name: account.enterpriseConnection?.name ?? '', - iconUrl: account.enterpriseConnection?.logoPublicUrl ?? undefined, + icon: toProviderIcon({ + logo: getEnterpriseLogo(account.provider), + iconUrl: account.enterpriseConnection?.logoPublicUrl, + label: account.enterpriseConnection?.name ?? '', + }), emailAddress: account.emailAddress, requiresAction: Boolean(account.verification?.error?.longMessage), })); const linkableConnections = connections .filter(connection => connection.allowOrganizationAccountLinking && !linkedConnectionIds.has(connection.id)) - .map(connection => ({ id: connection.id, name: connection.name, iconUrl: connection.logoPublicUrl ?? undefined })); + .map(connection => ({ + id: connection.id, + name: connection.name, + icon: toProviderIcon({ + logo: getEnterpriseLogo(connection.provider), + iconUrl: connection.logoPublicUrl, + label: connection.name, + }), + })); return visibleAccounts.length || linkableConnections.length ? { status: 'ready', accounts: visibleAccounts, connections: linkableConnections } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts index 1115299203d..9e18f6767d9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts @@ -1,28 +1,8 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../../../tokens.stylex'; - export const styles = stylex.create({ connectRow: { justifyContent: 'center' }, - fallback: { - borderRadius: radiusVars['--cl-radius-sm'], - alignItems: 'center', - backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, - color: colorVars['--cl-color-neutral'], - display: 'inline-flex', - flexShrink: 0, - fontSize: '0.625rem', - justifyContent: 'center', - height: '100%', - width: '100%', - }, text: { minWidth: 0, }, - icon: { - display: 'block', - objectFit: 'contain', - height: space['5'], - width: space['5'], - }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts index 65d8e73e13d..f470e38ae8e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts @@ -1,7 +1,9 @@ +import type { UserProfileProviderIconProps } from '../user-profile-provider-icon'; + export interface UserProfileEnterpriseConnection { id: string; name: string; - iconUrl?: string; + icon: UserProfileProviderIconProps; connectError?: string; } 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..346c7fc1f44 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.model.ts @@ -0,0 +1,14 @@ +import type { EnterpriseAccountResource } from '@clerk/shared/types'; + +import { getEnterpriseLogo } from '../../components/provider-logo/enterprise.generated'; +import type { UserProfileManagedBy } from './user-profile-managed-by'; + +type ManagingAccount = Pick & { + enterpriseConnection?: { name?: string | null } | null; +}; + +export function toManagedBy(account: ManagingAccount): UserProfileManagedBy { + const name = account.enterpriseConnection?.name || undefined; + const logo = getEnterpriseLogo(account.provider); + return { ...(name ? { name } : {}), ...(logo ? { logo } : {}) }; +} 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 ab01442a007..4c42bae073a 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,27 +1,46 @@ import * as stylex from '@stylexjs/stylex'; import { Icon } from '../../components/icon'; +import type { ProviderLogoGlyph } from '../../components/provider-logo'; +import { ProviderLogo } from '../../components/provider-logo'; import { Section, sectionCompactStyles } from '../../components/section'; import { Lock } from '../../icons/glyphs/lock'; +import { fill, useMessages } from '../../localization'; /** The enterprise connection a row's value comes from, which is why the row has nothing to edit. */ export interface UserProfileManagedBy { - name: string; + name?: string; + logo?: ProviderLogoGlyph; } -export function UserProfileManagedByLabel({ managedBy, label }: { managedBy: UserProfileManagedBy; label: string }) { +export function UserProfileManagedByLabel({ + managedBy, + template, +}: { + managedBy: UserProfileManagedBy; + template: string; +}) { + const m = useMessages('userProfile'); + const name = managedBy.name || m.enterpriseConnection; return ( + managedBy.logo ? ( + + ) : ( + + ) } > - {label} - {managedBy.name} + {fill(template, { name })} + {name} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 48dd013ffd8..0c686660f3b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -1,5 +1,5 @@ import { Section } from '../../../components/section'; -import { fill, useMessages } from '../../../localization'; +import { useMessages } from '../../../localization'; import { UserProfileManagedByLabel } from '../user-profile-managed-by'; import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; @@ -20,7 +20,7 @@ export function UserProfilePasswordRowView({ ) : managedBy ? ( ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index b9055babc0b..8b2081923a3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,7 +3,6 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', - enterpriseConnection: 'your enterprise connection', change: 'Change password', set: 'Set password', 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 0c3a7d01641..f79a510fa16 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 @@ -12,6 +12,8 @@ import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useErrorText, useLocale, useMessages } from '../../../localization'; import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { toManagedBy } from '../user-profile-managed-by.model'; import { passwordFormError } from './user-profile-password-errors'; import { passwordFieldFeedback } from './user-profile-password-feedback'; import type { UserProfileEditPasswordValue, UserProfilePasswordPolicy } from './user-profile-password-section.types'; @@ -21,7 +23,7 @@ type UnavailablePasswordModel = | { status: 'readonly'; mode: 'set' | 'change'; - managedBy: { name: string }; + managedBy: UserProfileManagedBy; }; export type UserProfilePasswordModel = @@ -61,7 +63,7 @@ function getAvailablePasswordUser( type PasswordPolicyResult = | { status: 'hidden' } - | { status: 'readonly'; mode: 'set' | 'change'; enterpriseConnectionName: string | undefined } + | { status: 'readonly'; mode: 'set' | 'change'; managedBy: UserProfileManagedBy } | (UserProfilePasswordPolicy & { status: 'ready'; userId: string }); function getPasswordPolicy( @@ -85,7 +87,7 @@ function getPasswordPolicy( return { status: 'readonly', mode: policy.mode, - enterpriseConnectionName: enterpriseAccount.enterpriseConnection?.name, + managedBy: toManagedBy(enterpriseAccount), }; } @@ -154,7 +156,7 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { return { status: 'readonly', mode: policy.mode, - managedBy: { name: policy.enterpriseConnectionName || m.enterpriseConnection }, + managedBy: policy.managedBy, }; } if (policy.status !== 'ready') { diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts index 9de819edbf7..139fe4c062f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../../tokens.stylex'; +import { colorVars, radiusVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ countdown: { @@ -17,7 +17,17 @@ export const styles = stylex.create({ }, providerIcon: { display: 'block', + objectFit: 'contain', height: space['5'], width: space['5'], }, + providerInitial: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], + display: 'inline-flex', + fontSize: '0.625rem', + justifyContent: 'center', + }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx index 66ce55261dd..b8ddf1df58f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { Section } from '../../../components/section'; -import { fill, useMessages } from '../../../localization'; +import { useMessages } from '../../../localization'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import { UserProfileManagedByLabel } from '../user-profile-managed-by'; @@ -26,7 +26,7 @@ export function UserProfileNameRowView({ name, managedBy, action }: UserProfileN ) : managedBy ? ( ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts index 3500cc19a3c..10991214a09 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts @@ -1,10 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; -import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; +import type { AttributeData, UserResource } from '@clerk/shared/types'; import { save } from '../../../utils/errors'; import { useReverificationFlow } from '../../reverification'; import { isAttributeAvailable } from '../user-profile.utils'; -import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { toManagedBy } from '../user-profile-managed-by.model'; import { useUserProfileUserModel } from '../user-profile-user.model'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -13,14 +13,6 @@ import type { UserProfileNameAttribute, UserProfileProfileSectionModel } from '. const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; -function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { - if (!account) { - return undefined; - } - const connection = account.enterpriseConnection; - return { name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, '') }; -} - function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } @@ -38,7 +30,8 @@ export function useUserProfileProfileSectionModel(): UserProfileProfileSectionMo const { attributes, usernameSettings } = environment.userSettings; const usernameImmutable = Boolean(attributes.username?.immutable); const showUsername = isAttributeAvailable(attributes.username) && !(usernameImmutable && !user.username); - const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); + const managingAccount = user.enterpriseAccounts.find(account => account.active); + const nameManagedBy = managingAccount ? toManagedBy(managingAccount) : undefined; return { status: 'ready', diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.test.ts new file mode 100644 index 00000000000..6ccdeadbd8a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { enterpriseLogos } from '../../components/provider-logo/enterprise.generated'; +import { oauthLogos } from '../../components/provider-logo/oauth.generated'; +import { toManagedBy } from './user-profile-managed-by.model'; +import { toProviderIcon } from './user-profile-provider-icon.model'; + +describe('toProviderIcon', () => { + it('prefers the bundled logo over an image URL', () => { + expect(toProviderIcon({ logo: oauthLogos.google, iconUrl: 'https://example.com/logo.png', label: 'Acme' })).toEqual( + { logo: oauthLogos.google }, + ); + }); + + it('falls back to the image URL without a bundled logo', () => { + expect(toProviderIcon({ iconUrl: ' https://example.com/logo.png ', label: 'Acme' })).toEqual({ + iconUrl: 'https://example.com/logo.png', + }); + }); + + it('falls back to the initial when there is no logo or image', () => { + expect(toProviderIcon({ iconUrl: null, label: ' acme SSO' })).toEqual({ initial: 'A' }); + }); +}); + +describe('toManagedBy', () => { + it('names the connection and carries its bundled enterprise logo', () => { + expect(toManagedBy({ provider: 'oidc_gitlab', enterpriseConnection: { name: 'Acme GitLab' } })).toEqual({ + name: 'Acme GitLab', + logo: enterpriseLogos.oidc_gitlab, + }); + }); + + it('leaves out a missing name and a logo it does not bundle', () => { + expect(toManagedBy({ provider: 'saml_custom', enterpriseConnection: null })).toEqual({}); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.ts b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.ts new file mode 100644 index 00000000000..1daf25887ed --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-provider-icon.model.ts @@ -0,0 +1,21 @@ +import type { ProviderLogoGlyph } from '../../components/provider-logo'; +import type { UserProfileProviderIconProps } from './user-profile-provider-icon'; + +export function toProviderIcon({ + logo, + iconUrl, + label, +}: { + logo?: ProviderLogoGlyph; + iconUrl?: string | null; + label: string; +}): UserProfileProviderIconProps { + if (logo) { + return { logo }; + } + const url = iconUrl?.trim(); + if (url) { + return { iconUrl: url }; + } + return { initial: label.trim().charAt(0).toUpperCase() }; +} 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 01eb9cd0498..a2f5e605997 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,28 +1,59 @@ import * as stylex from '@stylexjs/stylex'; import { Icon, IconFrame } from '../../components/icon'; +import type { ProviderLogoGlyph } from '../../components/provider-logo'; +import { ProviderLogo } from '../../components/provider-logo'; import { Section } from '../../components/section'; import type { IconComponent } from '../../icons/glyph'; import { styles } from './user-profile-profile-panel.styles'; -type UserProfileProviderIconProps = { iconUrl: string; glyph?: never } | { iconUrl?: never; glyph: IconComponent }; +export type UserProfileProviderIconProps = + | { logo: ProviderLogoGlyph } + | { iconUrl: string } + | { glyph: IconComponent } + | { initial: string }; + +function ProviderIconContent(props: UserProfileProviderIconProps) { + if ('logo' in props) { + return ( + + ); + } + if ('iconUrl' in props) { + return ( + + ); + } + if ('glyph' in props) { + return ( + + ); + } + return ( + + {props.initial} + + ); +} export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { return ( - {'iconUrl' in props ? ( - - ) : ( - - )} + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider-media.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-provider-media.styles.ts deleted file mode 100644 index f5ad38d6b2e..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-provider-media.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { colorVars, radiusVars, space } from '../../tokens.stylex'; - -export const styles = stylex.create({ - 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', - backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, - color: colorVars['--cl-color-neutral'], - display: 'inline-flex', - fontSize: '0.625rem', - justifyContent: 'center', - height: space['6'], - width: space['6'], - }, -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider-media.tsx b/packages/mosaic/src/features/user-profile/user-profile-provider-media.tsx deleted file mode 100644 index 52b3c75b331..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-provider-media.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { IconFrame } from '../../components/icon'; -import { Section } from '../../components/section'; -import { styles } from './user-profile-provider-media.styles'; - -export function UserProfileProviderMedia({ - provider, - iconUrl, - monochromeIcon, -}: { - provider: string; - iconUrl?: string; - monochromeIcon?: boolean; -}) { - const url = iconUrl?.trim(); - return ( - - - {url && monochromeIcon ? ( - - ) : url ? ( - - ) : ( - - {provider.trim().charAt(0).toUpperCase()} - - )} - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts deleted file mode 100644 index a1bd893453a..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okx_wallet', 'vercel', 'x']; - -export function isMonochromeProvider(provider: string): boolean { - return MONOCHROME_PROVIDERS.includes(provider); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts index 7940ccf8293..c79ae911c8d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { web3Logos } from '../../../../components/provider-logo/web3.generated'; import { normalizedWeb3Wallet, projectWeb3Wallets } from '../user-profile-web3-wallets-section.model'; const metamask = { @@ -29,7 +30,7 @@ describe('Web3 wallet projection', () => { }); }); - it('marks monochrome provider logos', () => { + it('uses bundled Web3 logos', () => { const okx = { ...metamask, id: 'wallet_okx', @@ -43,10 +44,10 @@ describe('Web3 wallet projection', () => { }); expect(result).toMatchObject({ wallets: [ - { id: 'wallet_metamask', monochromeIcon: false }, - { id: 'wallet_okx', monochromeIcon: true }, + { id: 'wallet_metamask', icon: { logo: web3Logos.metamask } }, + { id: 'wallet_okx', icon: { logo: web3Logos.okx_wallet } }, ], - availableProviders: [{ id: 'web3_coinbase_wallet_signature', monochromeIcon: false }], + availableProviders: [{ id: 'web3_coinbase_wallet_signature', icon: { logo: web3Logos.coinbase_wallet } }], }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx index 88bf6c8832a..6c6addbf7ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx @@ -10,7 +10,7 @@ import { Section } from '../../../components/section'; import { ArrowUpRight } from '../../../icons/glyphs/arrow-up-right'; import { fill, useMessages } from '../../../localization'; import { truncationStyles } from '../../../styles/typography.styles'; -import { UserProfileProviderMedia } from '../user-profile-provider-media'; +import { UserProfileProviderIcon } from '../user-profile-provider-icon'; import { styles } from './user-profile-web3-wallets.styles'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.types'; @@ -47,13 +47,7 @@ export function UserProfileWeb3WalletRowView({ return ( - {wallet.provider ? ( - - ) : null} + {wallet.icon ? : null} & { verification: Pick; }; @@ -78,8 +88,7 @@ export function projectWeb3Wallets({ id: wallet.id, address: wallet.web3Wallet, provider: provider?.name, - iconUrl: provider ? iconImageUrl(provider.provider) : undefined, - monochromeIcon: provider ? isMonochromeProvider(provider.provider) : false, + icon: provider ? toWeb3Icon(provider) : undefined, isPrimary: wallet.id === primaryId, isVerified: wallet.verification.status === 'verified', }; @@ -91,8 +100,7 @@ export function projectWeb3Wallets({ id: provider.strategy, walletPicker: provider.provider === 'solana' ? 'solana' : undefined, provider: provider.name, - iconUrl: iconImageUrl(provider.provider), - monochromeIcon: isMonochromeProvider(provider.provider), + icon: toWeb3Icon(provider), })) : [], }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index c3f8a3e5f0a..a291b11154b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -4,13 +4,13 @@ import type { Ref } from 'react'; import type { ConfirmController } from '../../../blocks/confirm'; import type { DialogFocusTarget } from '../../../components/dialog'; import type { ReverificationController } from '../../reverification'; +import type { UserProfileProviderIconProps } from '../user-profile-provider-icon'; export interface UserProfileWeb3Provider { id: Web3Strategy; walletPicker?: 'solana'; provider: string; - iconUrl?: string; - monochromeIcon?: boolean; + icon: UserProfileProviderIconProps; connectError?: string; } @@ -18,8 +18,7 @@ export interface UserProfileWeb3Wallet { id: string; address: string; provider?: string; - iconUrl?: string; - monochromeIcon?: boolean; + icon?: UserProfileProviderIconProps; isPrimary?: boolean; isVerified: boolean; canRemove?: boolean; diff --git a/packages/mosaic/src/features/user-profile/user-profile.messages.ts b/packages/mosaic/src/features/user-profile/user-profile.messages.ts index 7f95082be45..883d5a227c1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.messages.ts @@ -1,6 +1,7 @@ export const userProfileMessages = { /** Names the surface: its navigation landmark, and the dialog it opens in. */ label: 'User profile', + enterpriseConnection: 'your enterprise connection', pages: { account: 'Account', security: 'Security', diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 0b3c9019df7..7c531aca901 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -88,6 +88,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 817d54b17ca..76363c8e00c 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -226,6 +226,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 { Default as RadioDefault, Descriptions as RadioDescriptions, @@ -758,6 +764,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. @@ -1082,6 +1095,7 @@ export const registry: StoryModule[] = [ headingModule, iconModule, iconFrameModule, + providerLogoModule, menuComponentModule, otpComponentModule, paginationModule, diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts index 4a483df56ea..a5a7e169b3d 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -7,6 +7,7 @@ import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosText } from '@/lib/chaos'; +import { oauthLogos } from '@clerk/mosaic/components/provider-logo/oauth.generated'; import { idleReverification } from './reverification'; @@ -14,12 +15,12 @@ export const connectedAccount: UserProfileConnectedAccount = { id: 'google', provider: 'Google', identifier: 'test@example.com', - iconUrl: 'https://img.clerk.com/static/google.svg', + icon: { logo: oauthLogos.google }, status: 'connected', }; const connectionProviders: UserProfileConnectionProvider[] = [ - { id: 'google', provider: 'Google', iconUrl: 'https://img.clerk.com/static/google.svg' }, - { id: 'apple', provider: 'Apple', iconUrl: 'https://img.clerk.com/static/apple.svg' }, + { id: 'google', provider: 'Google', icon: { logo: oauthLogos.google } }, + { id: 'apple', provider: 'Apple', icon: { logo: oauthLogos.apple } }, ]; export function useConnectedAccountsFixture({ @@ -60,7 +61,7 @@ export function useConnectedAccountsFixture({ { id: provider.id, provider: provider.provider, - iconUrl: provider.iconUrl, + icon: provider.icon, identifier: 'test@example.com', status: 'connected', }, diff --git a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts index 12125133a33..384deee2434 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts @@ -6,19 +6,20 @@ import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosText } from '@/lib/chaos'; +import { enterpriseLogos } from '@clerk/mosaic/components/provider-logo/enterprise.generated'; export const accounts: UserProfileEnterpriseAccount[] = [ { id: 'account_okta', name: 'Acme Okta', emailAddress: 'alex@acme.com', - iconUrl: 'https://img.clerk.com/static/okta.svg', + icon: { logo: enterpriseLogos.saml_okta }, }, - { id: 'account_custom', name: 'Internal SSO', emailAddress: 'alex@internal.acme.com' }, + { id: 'account_custom', name: 'Internal SSO', emailAddress: 'alex@internal.acme.com', icon: { initial: 'I' } }, ]; const connections: UserProfileEnterpriseConnection[] = [ - { id: 'connection_google', name: 'Google Workspace', iconUrl: 'https://img.clerk.com/static/google.svg' }, - { id: 'connection_saml', name: 'Partner SAML' }, + { id: 'connection_google', name: 'Google Workspace', icon: { logo: enterpriseLogos.saml_google } }, + { id: 'connection_saml', name: 'Partner SAML', icon: { initial: 'P' } }, ]; export function useEnterpriseAccountsFixture({ @@ -57,7 +58,7 @@ export function useEnterpriseAccountsFixture({ setTimeout(() => { setLinkedAccounts(current => [ ...current, - { id: `account_${id}`, name: connection.name, iconUrl: connection.iconUrl, emailAddress: 'alex@acme.com' }, + { id: `account_${id}`, name: connection.name, icon: connection.icon, emailAddress: 'alex@acme.com' }, ]); setAvailableConnections(current => current.filter(item => item.id !== id)); setPendingId(undefined); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index b088ed86291..e1b1e0d12f1 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -1,3 +1,4 @@ +import { web3Logos } from '@clerk/mosaic/components/provider-logo/web3.generated'; import { useUserProfileWeb3WalletsController } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller'; import type { UserProfileWeb3Provider, @@ -16,11 +17,11 @@ interface DemoWallet extends UserProfileWeb3Wallet { } const providers: UserProfileWeb3Provider[] = [ - { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { id: 'web3_metamask_signature', provider: 'MetaMask', icon: { logo: web3Logos.metamask } }, { id: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', - iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + icon: { logo: web3Logos.coinbase_wallet }, }, ]; @@ -28,7 +29,7 @@ export const primaryWallet: DemoWallet = { id: 'wallet_1', providerId: 'web3_metamask_signature', provider: 'MetaMask', - iconUrl: 'https://img.clerk.com/static/metamask.svg', + icon: { logo: web3Logos.metamask }, address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', isPrimary: true, isVerified: true, @@ -38,7 +39,7 @@ export const secondaryWallet: DemoWallet = { id: 'wallet_2', providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', - iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + icon: { logo: web3Logos.coinbase_wallet }, address: '0x1234567890abcdef1234567890abcdef12345678', isVerified: true, }; @@ -90,7 +91,7 @@ export function useWeb3WalletsFixture({ ...current, { provider: provider.provider, - iconUrl: provider.iconUrl, + icon: provider.icon, id: `wallet_${id}`, providerId: id, address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/swingset/src/stories/provider-logo.mdx b/packages/swingset/src/stories/provider-logo.mdx new file mode 100644 index 00000000000..5821c840ea8 --- /dev/null +++ b/packages/swingset/src/stories/provider-logo.mdx @@ -0,0 +1,69 @@ +import * as ProviderLogoStories from './provider-logo.stories'; + +# ProviderLogo + +ProviderLogo renders the logo for an OAuth, Web3, or phone-code provider. 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 + +`ProviderLogo` renders the `glyph` it is given. The glyphs come in four groups, each in its own module, keyed by the provider id Clerk uses for that group: + +| Module | Map | Lookup | Keys | +| ---------------------- | ----------------- | ------------------- | --------------------------------- | +| `oauth.generated` | `oauthLogos` | `getOAuthLogo` | OAuth provider (`google`) | +| `enterprise.generated` | `enterpriseLogos` | `getEnterpriseLogo` | Enterprise provider (`saml_okta`) | +| `web3.generated` | `web3Logos` | `getWeb3Logo` | Web3 provider (`metamask`) | +| `phone.generated` | `phoneLogos` | `getPhoneLogo` | Phone code channel (`whatsapp`) | + +A feature imports only the groups it renders, and a logo in more than one group ships once. + +```tsx +import { ProviderLogo } from '@clerk/mosaic/components/provider-logo'; +import { oauthLogos } from '@clerk/mosaic/components/provider-logo/oauth.generated'; + +; +``` + +A lookup returns `undefined` for an id without a logo, such as a custom OAuth provider, which renders its own image instead. + +```tsx +import { getOAuthLogo } from '@clerk/mosaic/components/provider-logo/oauth.generated'; + +const glyph = getOAuthLogo(provider); + +glyph ? : ; +``` + +--- + +## 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..1f4880792ce --- /dev/null +++ b/packages/swingset/src/stories/provider-logo.stories.tsx @@ -0,0 +1,117 @@ +import type { ProviderLogoGlyph, ProviderLogoProps } from '@clerk/mosaic/components/provider-logo'; +import { ProviderLogo } from '@clerk/mosaic/components/provider-logo'; +import { enterpriseLogos } from '@clerk/mosaic/components/provider-logo/enterprise.generated'; +import { oauthLogos } from '@clerk/mosaic/components/provider-logo/oauth.generated'; +import { phoneLogos } from '@clerk/mosaic/components/provider-logo/phone.generated'; +import { web3Logos } from '@clerk/mosaic/components/provider-logo/web3.generated'; +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 glyphsById = new Map( + [oauthLogos, web3Logos, phoneLogos, enterpriseLogos] + .flatMap(group => Object.values(group)) + .map(glyph => [glyph.id, glyph]), +); +const glyphs = [...glyphsById.values()]; + +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: { + glyph: Object.fromEntries(glyphs.map(glyph => [glyph.id, {}])), + size: { sm: {}, md: {}, lg: {}, inherit: {} }, + }, + _defaultVariants: { + glyph: 'google', + size: 'md', + }, + }, +}; + +function knobsAsProps({ glyph, ...props }: Record): ProviderLogoProps { + return { ...props, glyph: glyphsById.get(String(glyph)) ?? oauthLogos.google }; +} + +export function Default(props: Record) { + return ; +} + +export function Sizes() { + return ( +
+ + + +
+ ); +} + +export function ColorSchemes() { + return ( +
+ {(['light', 'dark'] as const).map(scheme => ( +
+ {glyphs.map(glyph => ( + + ))} +
+ ))} +
+ ); +} diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 9119dbee3fb..6fc22e1b68e 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -3,6 +3,7 @@ import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/ import type { StoryMeta } from '@/lib/types'; import { connectedAccount, useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; +import { oauthLogos } from '@clerk/mosaic/components/provider-logo/oauth.generated'; export { default as __source } from './user-profile-connected-accounts-section.stories?raw'; export const meta: StoryMeta = { @@ -46,7 +47,7 @@ export function ConnectionError() { { id: 'apple', provider: 'Apple', - iconUrl: 'https://img.clerk.com/static/apple.svg', + icon: { logo: oauthLogos.apple }, connectError: 'Unable to connect. Please try again.', }, ], diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 471efaaaba4..38f4cb532ae 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`, or reads "your enterprise connection" when it is +unset. Below `26rem` of group width the label keeps only the name. ; + return ; } /** Every save is rejected, so the dialog shows both halves of a failure at once. */ diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 8420f89a827..6b672c11c28 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -1,3 +1,4 @@ +import { web3Logos } from '@clerk/mosaic/components/provider-logo/web3.generated'; import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -35,7 +36,7 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], - availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', icon: { logo: web3Logos.metamask } }], }); return ( =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} @@ -11185,6 +11192,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==} @@ -13972,6 +13982,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'} @@ -23845,6 +23860,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 @@ -27503,6 +27523,8 @@ snapshots: mdn-data@2.0.28: {} + mdn-data@2.0.30: {} + mdn-data@2.27.1: {} mdurl@2.0.0: {} @@ -31201,6 +31223,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