From a132f93d572482d985f168b674adad6d90575227 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 21 Jul 2026 11:32:28 +0530 Subject: [PATCH] Add support for embedded Avatars --- .../generate-anonymous-animal-icons.mjs | 112 ++++++++++ .../generate-anonymous-entity-icons.mjs | 113 +++++++++++ packages/browser/src/index.ts | 16 ++ packages/browser/src/utils/SPACryptoUtils.ts | 12 +- .../extractAvatarParamsFromUri.test.ts | 87 ++++++++ .../__tests__/generateAvatarDataUri.test.ts | 192 ++++++++++++++++++ .../utils/__tests__/logoUriSchemes.test.ts | 36 ++++ .../__tests__/pickAnonymousAvatarName.test.ts | 37 ++++ .../__tests__/pickAnonymousEntityName.test.ts | 37 ++++ .../utils/__tests__/resolveLogoUri.test.ts | 79 +++++++ .../utils/anonymousAnimalIcons.generated.ts | 127 ++++++++++++ .../utils/anonymousEntityIcons.generated.ts | 174 ++++++++++++++++ .../src/utils/extractAvatarParamsFromUri.ts | 101 +++++++++ .../src/utils/generateAvatarDataUri.ts | 183 +++++++++++++++++ packages/browser/src/utils/hashStr.ts | 37 ++++ packages/browser/src/utils/isAvatarUri.ts | 36 ++++ .../src/utils/pickAnonymousAvatarName.ts | 48 +++++ .../src/utils/pickAnonymousEntityName.ts | 49 +++++ packages/browser/src/utils/resolveLogoUri.ts | 75 +++++++ .../avatars/anonymous_animals/capybara.svg | 11 + .../avatars/anonymous_animals/chameleon.svg | 9 + .../avatars/anonymous_animals/chinchilla.svg | 13 ++ .../avatars/anonymous_animals/chipmunk.svg | 12 ++ .../avatars/anonymous_animals/chupacabra.svg | 11 + .../avatars/anonymous_animals/dingo.svg | 9 + .../avatars/anonymous_animals/dinosaur.svg | 8 + .../images/avatars/anonymous_animals/frog.svg | 11 + .../avatars/anonymous_animals/giraffe.svg | 11 + .../avatars/anonymous_animals/hedgehog.svg | 9 + .../avatars/anonymous_animals/hippo.svg | 12 ++ .../avatars/anonymous_animals/jackal.svg | 9 + .../avatars/anonymous_animals/jackalope.svg | 11 + .../images/avatars/anonymous_animals/mink.svg | 10 + .../avatars/anonymous_animals/otter.svg | 12 ++ .../avatars/anonymous_animals/platypus.svg | 7 + .../avatars/anonymous_animals/quagga.svg | 10 + .../avatars/anonymous_animals/raccoon.svg | 11 + .../avatars/anonymous_animals/skunk.svg | 9 + .../avatars/anonymous_entity/anchor.svg | 7 + .../avatars/anonymous_entity/antenna.svg | 6 + .../images/avatars/anonymous_entity/anvil.svg | 7 + .../images/avatars/anonymous_entity/arch.svg | 4 + .../avatars/anonymous_entity/bridge.svg | 7 + .../avatars/anonymous_entity/chevron.svg | 5 + .../avatars/anonymous_entity/circuit_node.svg | 9 + .../avatars/anonymous_entity/compass.svg | 5 + .../images/avatars/anonymous_entity/cube.svg | 6 + .../avatars/anonymous_entity/diamond.svg | 3 + .../images/avatars/anonymous_entity/dome.svg | 6 + .../images/avatars/anonymous_entity/gate.svg | 4 + .../avatars/anonymous_entity/hexagon.svg | 3 + .../images/avatars/anonymous_entity/key.svg | 7 + .../avatars/anonymous_entity/lighthouse.svg | 7 + .../images/avatars/anonymous_entity/lock.svg | 6 + .../avatars/anonymous_entity/obelisk.svg | 4 + .../avatars/anonymous_entity/octagon.svg | 3 + .../avatars/anonymous_entity/orbit_ring.svg | 5 + .../anonymous_entity/parallelogram.svg | 3 + .../avatars/anonymous_entity/pavilion.svg | 6 + .../avatars/anonymous_entity/pentagon.svg | 3 + .../avatars/anonymous_entity/plus_facet.svg | 3 + .../images/avatars/anonymous_entity/silo.svg | 6 + .../avatars/anonymous_entity/spiral.svg | 4 + .../images/avatars/anonymous_entity/spire.svg | 5 + .../images/avatars/anonymous_entity/star.svg | 3 + .../images/avatars/anonymous_entity/tower.svg | 8 + .../avatars/anonymous_entity/townhouse.svg | 7 + .../anonymous_entity/triangle_stack.svg | 5 + .../avatars/anonymous_entity/turbine.svg | 5 + .../images/avatars/anonymous_entity/valve.svg | 5 + .../avatars/anonymous_entity/windmill.svg | 6 + ...677941df85b03d5d2cfa78a06ab03580dd9ac6.png | Bin 0 -> 2082 bytes ...ab8c5a94eed1b7587e7dbb5fed1db2c644801d.png | Bin 0 -> 6923 bytes ...7d509e64e503baebcb9f280cb5bd323b6d722e.png | Bin 0 -> 2082 bytes .../components/adapters/ImageComponent.tsx | 10 +- .../__tests__/ImageComponent.test.tsx | 76 +++++++ ...oji--spec-as-its-glyph--not-an-image-1.png | Bin 0 -> 6923 bytes ...ow-when-type-REGISTRATION-is-present-1.png | Bin 0 -> 2082 bytes ...-when-type-AUTHENTICATION-is-present-1.png | Bin 0 -> 2082 bytes .../UserAvatar/BaseUserAvatar.tsx | 26 ++- .../components/primitives/Avatar/Avatar.tsx | 28 ++- .../Avatar/__tests__/Avatar.test.tsx | 71 +++++++ .../components/primitives/Logo/Logo.styles.ts | 27 +++ .../src/components/primitives/Logo/Logo.tsx | 29 ++- packages/react/src/index.ts | 2 + .../components/primitives/Logo/Logo.css.ts | 8 + .../src/components/primitives/Logo/Logo.ts | 31 ++- 87 files changed, 2198 insertions(+), 29 deletions(-) create mode 100644 packages/browser/scripts/generate-anonymous-animal-icons.mjs create mode 100644 packages/browser/scripts/generate-anonymous-entity-icons.mjs create mode 100644 packages/browser/src/utils/__tests__/extractAvatarParamsFromUri.test.ts create mode 100644 packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts create mode 100644 packages/browser/src/utils/__tests__/logoUriSchemes.test.ts create mode 100644 packages/browser/src/utils/__tests__/pickAnonymousAvatarName.test.ts create mode 100644 packages/browser/src/utils/__tests__/pickAnonymousEntityName.test.ts create mode 100644 packages/browser/src/utils/__tests__/resolveLogoUri.test.ts create mode 100644 packages/browser/src/utils/anonymousAnimalIcons.generated.ts create mode 100644 packages/browser/src/utils/anonymousEntityIcons.generated.ts create mode 100644 packages/browser/src/utils/extractAvatarParamsFromUri.ts create mode 100644 packages/browser/src/utils/generateAvatarDataUri.ts create mode 100644 packages/browser/src/utils/hashStr.ts create mode 100644 packages/browser/src/utils/isAvatarUri.ts create mode 100644 packages/browser/src/utils/pickAnonymousAvatarName.ts create mode 100644 packages/browser/src/utils/pickAnonymousEntityName.ts create mode 100644 packages/browser/src/utils/resolveLogoUri.ts create mode 100644 packages/browser/static/images/avatars/anonymous_animals/capybara.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/chameleon.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/chinchilla.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/chipmunk.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/chupacabra.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/dingo.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/dinosaur.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/frog.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/giraffe.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/hedgehog.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/hippo.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/jackal.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/jackalope.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/mink.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/otter.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/platypus.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/quagga.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/raccoon.svg create mode 100644 packages/browser/static/images/avatars/anonymous_animals/skunk.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/anchor.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/antenna.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/anvil.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/arch.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/bridge.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/chevron.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/circuit_node.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/compass.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/cube.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/diamond.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/dome.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/gate.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/hexagon.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/key.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/lighthouse.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/lock.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/obelisk.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/octagon.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/orbit_ring.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/parallelogram.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/pavilion.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/pentagon.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/plus_facet.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/silo.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/spiral.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/spire.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/star.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/tower.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/townhouse.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/triangle_stack.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/turbine.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/valve.svg create mode 100644 packages/browser/static/images/avatars/anonymous_entity/windmill.svg create mode 100644 packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png create mode 100644 packages/react/.vitest-attachments/3dab8c5a94eed1b7587e7dbb5fed1db2c644801d.png create mode 100644 packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png create mode 100644 packages/react/src/components/adapters/__tests__/ImageComponent.test.tsx create mode 100644 packages/react/src/components/adapters/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png create mode 100644 packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png create mode 100644 packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-an-authentication-flow-when-type-AUTHENTICATION-is-present-1.png create mode 100644 packages/react/src/components/primitives/Avatar/__tests__/Avatar.test.tsx diff --git a/packages/browser/scripts/generate-anonymous-animal-icons.mjs b/packages/browser/scripts/generate-anonymous-animal-icons.mjs new file mode 100644 index 0000000..86d1162 --- /dev/null +++ b/packages/browser/scripts/generate-anonymous-animal-icons.mjs @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Regenerates `src/utils/anonymousAnimalIcons.generated.ts` from the SVG files in + * `static/images/anonymous`. Each source SVG is a self-contained badge (a colored + * rounded-square background + white icon paths). This script strips the background + * ``, records its fill color as the animal's default color, and replaces any + * reuse of that same color elsewhere in the markup (accent details like eyes/nostrils) + * with a `%COLOR%` placeholder so `generateAvatarDataUri` can re-color the whole icon + * (background + accents) together when a caller supplies an explicit `bg` override. + * + * Run this whenever an icon is added, removed, or replaced under `static/images/anonymous`. + * + * Usage: node scripts/generate-anonymous-animal-icons.mjs + */ + +import {readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const dir = join(root, 'static', 'images', 'avatars', 'anonymous_animals'); + +const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const names = readdirSync(dir) + .filter((file) => file.endsWith('.svg')) + .map((file) => file.replace(/\.svg$/, '')) + .sort(); + +const icons = names.map((name) => { + const svg = readFileSync(join(dir, `${name}.svg`), 'utf-8'); + + const rectMatch = svg.match(/]*fill="(#[0-9a-fA-F]{3,8})"[^>]*\/>/); + if (!rectMatch) { + throw new Error(`${name}.svg: could not find a background `); + } + const color = rectMatch[1]; + + const withoutRect = svg.replace(rectMatch[0], ''); + const colorPattern = new RegExp(escapeRegExp(color), 'gi'); + const markup = withoutRect + .replace(/]*>/, '') + .replace('', '') + .replace(colorPattern, '%COLOR%') + .trim(); + + return {color, markup, name}; +}); + +const banner = `/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// AUTO-GENERATED FILE. Do not edit directly. +// Regenerate with: node scripts/generate-anonymous-animal-icons.mjs +// Source SVGs live in static/images/anonymous. + +export interface AnonymousAnimalIcon { + /** The animal's curated default background color (hex). */ + color: string; + /** Inner SVG markup (no outer /). Any "%COLOR%" token is the animal's + * color, substitutable at render time so a custom "bg" recolors accents too. */ + markup: string; +} + +`; + +const entries = icons + .map( + ({name, color, markup}) => + ` ${JSON.stringify(name)}: {color: ${JSON.stringify(color)}, markup: ${JSON.stringify(markup)}},`, + ) + .join('\n'); + +const output = `${banner}export const ANONYMOUS_ANIMAL_ICONS: Record = {\n${entries}\n};\n`; + +writeFileSync(join(root, 'src', 'utils', 'anonymousAnimalIcons.generated.ts'), output); + +// eslint-disable-next-line no-console +console.log(`Generated anonymousAnimalIcons.generated.ts with ${icons.length} anonymous animal icon(s).`); diff --git a/packages/browser/scripts/generate-anonymous-entity-icons.mjs b/packages/browser/scripts/generate-anonymous-entity-icons.mjs new file mode 100644 index 0000000..191d119 --- /dev/null +++ b/packages/browser/scripts/generate-anonymous-entity-icons.mjs @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Regenerates `src/utils/anonymousEntityIcons.generated.ts` from the SVG files in + * `static/images/entity`. Each source SVG is a self-contained badge (a colored + * rounded-square background + white icon paths) representing a non-human entity + * (application, organization, or resource server). This script strips the background + * ``, records its fill color as the entity icon's default color, and replaces any + * reuse of that same color elsewhere in the markup with a `%COLOR%` placeholder so + * `generateAvatarDataUri` can re-color the whole icon (background + accents) together + * when a caller supplies an explicit `bg` override. + * + * Run this whenever an icon is added, removed, or replaced under `static/images/entity`. + * + * Usage: node scripts/generate-anonymous-entity-icons.mjs + */ + +import {readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const dir = join(root, 'static', 'images', 'avatars', 'anonymous_entity'); + +const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const names = readdirSync(dir) + .filter((file) => file.endsWith('.svg')) + .map((file) => file.replace(/\.svg$/, '')) + .sort(); + +const icons = names.map((name) => { + const svg = readFileSync(join(dir, `${name}.svg`), 'utf-8'); + + const rectMatch = svg.match(/]*fill="(#[0-9a-fA-F]{3,8})"[^>]*\/>/); + if (!rectMatch) { + throw new Error(`${name}.svg: could not find a background `); + } + const color = rectMatch[1]; + + const withoutRect = svg.replace(rectMatch[0], ''); + const colorPattern = new RegExp(escapeRegExp(color), 'gi'); + const markup = withoutRect + .replace(/]*>/, '') + .replace('', '') + .replace(colorPattern, '%COLOR%') + .trim(); + + return {color, markup, name}; +}); + +const banner = `/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// AUTO-GENERATED FILE. Do not edit directly. +// Regenerate with: node scripts/generate-anonymous-entity-icons.mjs +// Source SVGs live in static/images/entity. + +export interface AnonymousEntityIcon { + /** The entity icon's curated default background color (hex). */ + color: string; + /** Inner SVG markup (no outer /). Any "%COLOR%" token is the icon's + * color, substitutable at render time so a custom "bg" recolors accents too. */ + markup: string; +} + +`; + +const entries = icons + .map( + ({name, color, markup}) => + ` ${JSON.stringify(name)}: {color: ${JSON.stringify(color)}, markup: ${JSON.stringify(markup)}},`, + ) + .join('\n'); + +const output = `${banner}export const ANONYMOUS_ENTITY_ICONS: Record = {\n${entries}\n};\n`; + +writeFileSync(join(root, 'src', 'utils', 'anonymousEntityIcons.generated.ts'), output); + +// eslint-disable-next-line no-console +console.log(`Generated anonymousEntityIcons.generated.ts with ${icons.length} anonymous entity icon(s).`); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b4e67fe..6031e81 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -54,6 +54,22 @@ export {default as navigate} from './utils/navigate'; export {default as http} from './utils/http'; export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; export {default as resolveEmojiUrisInHtml} from './utils/resolveEmojiUrisInHtml'; +export {default as isAvatarUri, AVATAR_URI_SCHEME} from './utils/isAvatarUri'; +export {default as extractAvatarParamsFromUri} from './utils/extractAvatarParamsFromUri'; +export type {AvatarParams, AvatarShape, AvatarVariant} from './utils/extractAvatarParamsFromUri'; +export { + default as generateAvatarDataUri, + deriveAvatarContent, + AVATAR_GRADIENT_COUNT, +} from './utils/generateAvatarDataUri'; +export {default as pickAnonymousAvatarName} from './utils/pickAnonymousAvatarName'; +export {default as pickAnonymousEntityName} from './utils/pickAnonymousEntityName'; +export {ANONYMOUS_ANIMAL_ICONS} from './utils/anonymousAnimalIcons.generated'; +export type {AnonymousAnimalIcon} from './utils/anonymousAnimalIcons.generated'; +export {ANONYMOUS_ENTITY_ICONS} from './utils/anonymousEntityIcons.generated'; +export type {AnonymousEntityIcon} from './utils/anonymousEntityIcons.generated'; +export {default as resolveLogoUri} from './utils/resolveLogoUri'; +export type {ResolvedLogo, ResolvedLogoKind} from './utils/resolveLogoUri'; // Theme export {detectThemeMode, createClassObserver, createMediaQueryListener} from './theme/themeDetection'; diff --git a/packages/browser/src/utils/SPACryptoUtils.ts b/packages/browser/src/utils/SPACryptoUtils.ts index b81e189..a6a0200 100644 --- a/packages/browser/src/utils/SPACryptoUtils.ts +++ b/packages/browser/src/utils/SPACryptoUtils.ts @@ -21,7 +21,9 @@ import {ThunderIDAuthException, Crypto, JWKInterface} from '@thunderid/javascrip import base64url from 'base64url'; import sha256 from 'fast-sha256'; import {createLocalJWKSet, jwtVerify, JWTVerifyOptions} from 'jose'; -import randombytes from 'randombytes'; + +// `crypto.getRandomValues` throws once a single request exceeds this many bytes. +const MAX_RANDOM_VALUES_BYTES = 65_536; /** * Browser-side `Crypto` implementation using native Web Crypto APIs and `jose` for JWT verification. @@ -64,7 +66,13 @@ class SPACryptoUtils implements Crypto { * @returns A `Buffer` of random bytes. */ public generateRandomBytes(length: number): string | Buffer { - return randombytes(length); + const bytes: Uint8Array = new Uint8Array(length); + + for (let offset = 0; offset < length; offset += MAX_RANDOM_VALUES_BYTES) { + crypto.getRandomValues(bytes.subarray(offset, offset + MAX_RANDOM_VALUES_BYTES)); + } + + return Buffer.from(bytes); } /** diff --git a/packages/browser/src/utils/__tests__/extractAvatarParamsFromUri.test.ts b/packages/browser/src/utils/__tests__/extractAvatarParamsFromUri.test.ts new file mode 100644 index 0000000..48579b6 --- /dev/null +++ b/packages/browser/src/utils/__tests__/extractAvatarParamsFromUri.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import extractAvatarParamsFromUri from '../extractAvatarParamsFromUri'; + +describe('extractAvatarParamsFromUri', () => { + it('parses shape, variant, content, and colors', () => { + expect(extractAvatarParamsFromUri('avatar:shape=circle,variant=two_letter,content=BM,colors=2')).toEqual({ + colors: 2, + content: 'BM', + shape: 'circle', + variant: 'two_letter', + }); + }); + + it('parses an anonymous_animal spec', () => { + expect(extractAvatarParamsFromUri('avatar:shape=rounded,variant=anonymous_animal,content=jackalope')).toEqual({ + colors: 0, + content: 'jackalope', + shape: 'rounded', + variant: 'anonymous_animal', + }); + }); + + it('parses an anonymous_entity spec', () => { + expect(extractAvatarParamsFromUri('avatar:shape=rounded,variant=anonymous_entity,content=hexagon')).toEqual({ + colors: 0, + content: 'hexagon', + shape: 'rounded', + variant: 'anonymous_entity', + }); + }); + + it('parses a blank spec', () => { + expect(extractAvatarParamsFromUri('avatar:shape=circle,variant=blank,content=,colors=3')).toEqual({ + colors: 3, + content: '', + shape: 'circle', + variant: 'blank', + }); + }); + + it('parses an explicit bg override', () => { + expect(extractAvatarParamsFromUri('avatar:shape=circle,variant=one_letter,content=A,bg=#FF5733')).toEqual({ + bg: '#FF5733', + colors: 0, + content: 'A', + shape: 'circle', + variant: 'one_letter', + }); + }); + + it('falls back to defaults for missing or invalid params', () => { + expect(extractAvatarParamsFromUri('avatar:')).toEqual({ + colors: 0, + content: '', + shape: 'rounded', + variant: 'two_letter', + }); + expect(extractAvatarParamsFromUri('avatar:shape=bogus,variant=bogus,colors=notanumber')).toEqual({ + colors: 0, + content: '', + shape: 'rounded', + variant: 'two_letter', + }); + }); + + it('keeps = characters inside the content value', () => { + expect(extractAvatarParamsFromUri('avatar:shape=circle,content=a=b,colors=1').content).toBe('a=b'); + }); +}); diff --git a/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts b/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts new file mode 100644 index 0000000..ac8f47f --- /dev/null +++ b/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import type {AvatarParams} from '../extractAvatarParamsFromUri'; +import generateAvatarDataUri, {deriveAvatarContent, AVATAR_GRADIENT_COUNT} from '../generateAvatarDataUri'; + +const params = (overrides: Partial): AvatarParams => ({ + colors: 0, + content: '', + shape: 'rounded', + variant: 'two_letter', + ...overrides, +}); + +describe('generateAvatarDataUri — letter variants', () => { + it('is deterministic for the same inputs', () => { + const p = params({colors: 1, content: 'AC', shape: 'circle'}); + expect(generateAvatarDataUri(p)).toBe(generateAvatarDataUri({...p})); + }); + + it('renders a circle for shape=circle and a rounded rect otherwise', () => { + expect(decodeURIComponent(generateAvatarDataUri(params({content: 'AC', shape: 'circle'})))).toContain(' { + expect(decodeURIComponent(generateAvatarDataUri(params({content: 'AC', variant: 'two_letter'})))).toContain('>AC<'); + expect(decodeURIComponent(generateAvatarDataUri(params({content: 'AC', variant: 'one_letter'})))).toContain('>A<'); + }); + + it('defaults content to "A" when empty', () => { + expect(decodeURIComponent(generateAvatarDataUri(params({content: ''})))).toContain('>A<'); + }); + + it('rotates the gradient palette as colors changes', () => { + const base = generateAvatarDataUri(params({content: 'Acme', colors: 0})); + expect(generateAvatarDataUri(params({content: 'Acme', colors: 1}))).not.toBe(base); + expect(generateAvatarDataUri(params({content: 'Acme', colors: 10}))).not.toBe(base); + expect(generateAvatarDataUri(params({content: 'Acme', colors: -3}))).toMatch(/^data:image\/svg\+xml,/); + }); + + it('uses a flat bg color instead of a gradient when provided', () => { + const svg = decodeURIComponent(generateAvatarDataUri(params({content: 'AC', bg: '#FF5733'}))); + expect(svg).not.toContain(' { + it('renders the gradient background with no text', () => { + const svg = decodeURIComponent(generateAvatarDataUri(params({content: '', variant: 'blank'}))); + expect(svg).not.toContain(' { + const base = generateAvatarDataUri(params({content: '', variant: 'blank', colors: 0})); + expect(generateAvatarDataUri(params({content: '', variant: 'blank', colors: 1}))).not.toBe(base); + }); + + it('uses a flat bg color instead of a gradient when provided', () => { + const svg = decodeURIComponent(generateAvatarDataUri(params({content: '', variant: 'blank', bg: '#FF5733'}))); + expect(svg).not.toContain(' { + it('matches the number of distinct gradient rotations', () => { + expect(AVATAR_GRADIENT_COUNT).toBeGreaterThan(0); + const seen = new Set( + Array.from({length: AVATAR_GRADIENT_COUNT}, (_, colors) => + generateAvatarDataUri(params({content: 'Acme', variant: 'blank', colors})), + ), + ); + expect(seen.size).toBe(AVATAR_GRADIENT_COUNT); + }); +}); + +describe('generateAvatarDataUri — anonymous_animal variant', () => { + it('renders the requested animal with its curated color', () => { + const svg = decodeURIComponent( + generateAvatarDataUri(params({content: 'jackalope', shape: 'rounded', variant: 'anonymous_animal'})), + ); + expect(svg).toContain('fill="#2E9E9E"'); + }); + + it('recolors both background and accent details when bg is provided', () => { + const svg = decodeURIComponent( + generateAvatarDataUri(params({content: 'mink', variant: 'anonymous_animal', bg: '#112233'})), + ); + expect(svg.match(/#112233/gi)?.length).toBeGreaterThan(1); + expect(svg).not.toContain('#3B7DD8'); + }); + + it('renders a circle for shape=circle and a rounded rect otherwise', () => { + expect( + decodeURIComponent( + generateAvatarDataUri(params({content: 'otter', variant: 'anonymous_animal', shape: 'circle'})), + ), + ).toContain(' { + expect(generateAvatarDataUri(params({content: 'not-a-real-animal', variant: 'anonymous_animal'}))).toMatch( + /^data:image\/svg\+xml,/, + ); + }); +}); + +describe('generateAvatarDataUri — anonymous_entity variant', () => { + it('renders the requested entity icon with its curated color', () => { + const svg = decodeURIComponent( + generateAvatarDataUri(params({content: 'hexagon', shape: 'rounded', variant: 'anonymous_entity'})), + ); + expect(svg).toContain('fill="#3B7DD8"'); + }); + + it('recolors both background and accent details when bg is provided', () => { + const svg = decodeURIComponent( + generateAvatarDataUri(params({content: 'lock', variant: 'anonymous_entity', bg: '#112233'})), + ); + expect(svg.match(/#112233/gi)?.length).toBeGreaterThan(1); + expect(svg).not.toContain('#E0348E'); + }); + + it('renders a circle for shape=circle and a rounded rect otherwise', () => { + expect( + decodeURIComponent( + generateAvatarDataUri(params({content: 'star', variant: 'anonymous_entity', shape: 'circle'})), + ), + ).toContain(' { + expect(generateAvatarDataUri(params({content: 'not-a-real-entity', variant: 'anonymous_entity'}))).toMatch( + /^data:image\/svg\+xml,/, + ); + }); +}); + +describe('deriveAvatarContent', () => { + it('extracts one or two initials from a raw seed name', () => { + expect(deriveAvatarContent('two_letter', 'Jane Doe')).toBe('JA'); + expect(deriveAvatarContent('one_letter', 'Jane Doe')).toBe('J'); + }); + + it('handles a seed with no alphanumeric characters', () => { + expect(deriveAvatarContent('two_letter', '🚀')).toBe('A'); + }); + + it('hash-picks a valid anonymous animal key for the same seed deterministically', () => { + expect(deriveAvatarContent('anonymous_animal', 'session-abc')).toBe( + deriveAvatarContent('anonymous_animal', 'session-abc'), + ); + }); + + it('hash-picks a valid anonymous entity key for the same seed deterministically', () => { + expect(deriveAvatarContent('anonymous_entity', 'app-abc')).toBe(deriveAvatarContent('anonymous_entity', 'app-abc')); + }); + + it('is always empty for the blank variant', () => { + expect(deriveAvatarContent('blank', 'Jane Doe')).toBe(''); + expect(deriveAvatarContent('blank', '')).toBe(''); + }); +}); diff --git a/packages/browser/src/utils/__tests__/logoUriSchemes.test.ts b/packages/browser/src/utils/__tests__/logoUriSchemes.test.ts new file mode 100644 index 0000000..a40d8c4 --- /dev/null +++ b/packages/browser/src/utils/__tests__/logoUriSchemes.test.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {extractEmojiFromUri, isEmojiUri} from '@thunderid/javascript'; +import {describe, it, expect} from 'vitest'; +import isAvatarUri from '../isAvatarUri'; + +describe('logo URI scheme predicates', () => { + it('recognizes only their own scheme', () => { + expect(isEmojiUri('emoji:🐯')).toBe(true); + expect(isAvatarUri('avatar:shape=circle,variant=two_letter,content=BM')).toBe(true); + + const specs = ['emoji:🐯', 'avatar:shape=circle', 'https://example.com/logo.png']; + expect(specs.filter(isEmojiUri)).toEqual(['emoji:🐯']); + expect(specs.filter(isAvatarUri)).toEqual(['avatar:shape=circle']); + }); + + it('extracts the payload from their own scheme', () => { + expect(extractEmojiFromUri('emoji:🐯')).toBe('🐯'); + }); +}); diff --git a/packages/browser/src/utils/__tests__/pickAnonymousAvatarName.test.ts b/packages/browser/src/utils/__tests__/pickAnonymousAvatarName.test.ts new file mode 100644 index 0000000..1d37ae5 --- /dev/null +++ b/packages/browser/src/utils/__tests__/pickAnonymousAvatarName.test.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import {ANONYMOUS_ANIMAL_ICONS} from '../anonymousAnimalIcons.generated'; +import pickAnonymousAvatarName from '../pickAnonymousAvatarName'; + +describe('pickAnonymousAvatarName', () => { + it('always returns a name present in the bundled anonymous icon set', () => { + expect(Object.keys(ANONYMOUS_ANIMAL_ICONS)).toContain(pickAnonymousAvatarName('some-seed')); + expect(Object.keys(ANONYMOUS_ANIMAL_ICONS)).toContain(pickAnonymousAvatarName()); + }); + + it('is deterministic for the same seed', () => { + expect(pickAnonymousAvatarName('session-abc123')).toBe(pickAnonymousAvatarName('session-abc123')); + }); + + it('can differ across seeds', () => { + const names = new Set(['a', 'b', 'c', 'd', 'e'].map((seed) => pickAnonymousAvatarName(seed))); + expect(names.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/browser/src/utils/__tests__/pickAnonymousEntityName.test.ts b/packages/browser/src/utils/__tests__/pickAnonymousEntityName.test.ts new file mode 100644 index 0000000..a6c69ee --- /dev/null +++ b/packages/browser/src/utils/__tests__/pickAnonymousEntityName.test.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import {ANONYMOUS_ENTITY_ICONS} from '../anonymousEntityIcons.generated'; +import pickAnonymousEntityName from '../pickAnonymousEntityName'; + +describe('pickAnonymousEntityName', () => { + it('always returns a name present in the bundled anonymous icon set', () => { + expect(Object.keys(ANONYMOUS_ENTITY_ICONS)).toContain(pickAnonymousEntityName('some-seed')); + expect(Object.keys(ANONYMOUS_ENTITY_ICONS)).toContain(pickAnonymousEntityName()); + }); + + it('is deterministic for the same seed', () => { + expect(pickAnonymousEntityName('app-abc123')).toBe(pickAnonymousEntityName('app-abc123')); + }); + + it('can differ across seeds', () => { + const names = new Set(['a', 'b', 'c', 'd', 'e'].map((seed) => pickAnonymousEntityName(seed))); + expect(names.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/browser/src/utils/__tests__/resolveLogoUri.test.ts b/packages/browser/src/utils/__tests__/resolveLogoUri.test.ts new file mode 100644 index 0000000..31af35a --- /dev/null +++ b/packages/browser/src/utils/__tests__/resolveLogoUri.test.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import {ANONYMOUS_ANIMAL_ICONS} from '../anonymousAnimalIcons.generated'; +import {ANONYMOUS_ENTITY_ICONS} from '../anonymousEntityIcons.generated'; +import generateAvatarDataUri from '../generateAvatarDataUri'; +import resolveLogoUri from '../resolveLogoUri'; + +describe('resolveLogoUri', () => { + it('resolves an emoji: spec to its glyph', () => { + expect(resolveLogoUri('emoji:🐼')).toEqual({glyph: '🐼', kind: 'emoji'}); + }); + + it('resolves an avatar: spec with explicit content to a generated data URI', () => { + const resolved = resolveLogoUri('avatar:shape=circle,variant=two_letter,content=AC,colors=2'); + expect(resolved.kind).toBe('avatar'); + expect(resolved.imgSrc).toBe( + generateAvatarDataUri({colors: 2, content: 'AC', shape: 'circle', variant: 'two_letter'}), + ); + }); + + it('derives content from the seed text when the spec has none', () => { + const resolved = resolveLogoUri('avatar:shape=rounded,variant=two_letter,colors=0', 'Jane Doe'); + expect(decodeURIComponent(resolved.imgSrc ?? '')).toContain('>JA<'); + }); + + it('resolves an anonymous_animal spec to its bundled icon markup', () => { + const [name, icon] = Object.entries(ANONYMOUS_ANIMAL_ICONS)[0]; + const resolved = resolveLogoUri(`avatar:shape=rounded,variant=anonymous_animal,content=${name}`); + expect(resolved.kind).toBe('avatar'); + expect(decodeURIComponent(resolved.imgSrc ?? '')).toContain(icon.color); + }); + + it('resolves an anonymous_entity spec to its bundled icon markup', () => { + const [name, icon] = Object.entries(ANONYMOUS_ENTITY_ICONS)[0]; + const resolved = resolveLogoUri(`avatar:shape=rounded,variant=anonymous_entity,content=${name}`); + expect(resolved.kind).toBe('avatar'); + expect(decodeURIComponent(resolved.imgSrc ?? '')).toContain(icon.color); + }); + + it('resolves a plain URL as-is', () => { + expect(resolveLogoUri('https://example.com/logo.png')).toEqual({ + imgSrc: 'https://example.com/logo.png', + kind: 'url', + }); + }); + + it('bundles the full curated anonymous-animal icon set', () => { + expect(Object.keys(ANONYMOUS_ANIMAL_ICONS)).toHaveLength(19); + Object.values(ANONYMOUS_ANIMAL_ICONS).forEach((icon) => { + expect(icon.color).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(icon.markup.length).toBeGreaterThan(0); + }); + }); + + it('bundles the full curated anonymous-entity icon set', () => { + expect(Object.keys(ANONYMOUS_ENTITY_ICONS)).toHaveLength(33); + Object.values(ANONYMOUS_ENTITY_ICONS).forEach((icon) => { + expect(icon.color).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(icon.markup.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/browser/src/utils/anonymousAnimalIcons.generated.ts b/packages/browser/src/utils/anonymousAnimalIcons.generated.ts new file mode 100644 index 0000000..4bd2154 --- /dev/null +++ b/packages/browser/src/utils/anonymousAnimalIcons.generated.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// AUTO-GENERATED FILE. Do not edit directly. +// Regenerate with: node scripts/generate-anonymous-animal-icons.mjs +// Source SVGs live in static/images/anonymous. + +export interface AnonymousAnimalIcon { + /** The animal's curated default background color (hex). */ + color: string; + /** Inner SVG markup (no outer /). Any "%COLOR%" token is the animal's + * color, substitutable at render time so a custom "bg" recolors accents too. */ + markup: string; +} + +export const ANONYMOUS_ANIMAL_ICONS: Record = { + capybara: { + color: '#8A6A2E', + markup: + '\n\n\n\n\n', + }, + chameleon: { + color: '#C23B7A', + markup: + '\n\n\n', + }, + chinchilla: { + color: '#5B6BC0', + markup: + '\n\n\n\n\n\n\n', + }, + chipmunk: { + color: '#B5522C', + markup: + '\n\n\n\n\n\n', + }, + chupacabra: { + color: '#4B7A3F', + markup: + '\n\n\n\n\n', + }, + dingo: { + color: '#B5892C', + markup: + '\n\n\n', + }, + dinosaur: { + color: '#2E8F5B', + markup: + '\n\n', + }, + frog: { + color: '#4FA33F', + markup: + '\n\n\n\n\n', + }, + giraffe: { + color: '#C99A2E', + markup: + '\n\n\n\n\n', + }, + hedgehog: { + color: '#8A9A3B', + markup: + '\n\n\n', + }, + hippo: { + color: '#5B7A9E', + markup: + '\n\n\n\n\n\n', + }, + jackal: { + color: '#D6822C', + markup: + '\n\n\n', + }, + jackalope: { + color: '#2E9E9E', + markup: + '\n\n\n\n\n', + }, + mink: { + color: '#3B7DD8', + markup: + '\n\n\n\n', + }, + otter: { + color: '#E8622C', + markup: + '\n\n\n\n\n\n', + }, + platypus: { + color: '#C23B7A', + markup: + '\n', + }, + quagga: { + color: '#D6455C', + markup: + '\n\n\n\n', + }, + raccoon: { + color: '#E0348E', + markup: + '\n\n\n\n\n', + }, + skunk: { + color: '#7B3F9E', + markup: + '\n\n\n', + }, +}; diff --git a/packages/browser/src/utils/anonymousEntityIcons.generated.ts b/packages/browser/src/utils/anonymousEntityIcons.generated.ts new file mode 100644 index 0000000..8a88218 --- /dev/null +++ b/packages/browser/src/utils/anonymousEntityIcons.generated.ts @@ -0,0 +1,174 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// AUTO-GENERATED FILE. Do not edit directly. +// Regenerate with: node scripts/generate-anonymous-entity-icons.mjs +// Source SVGs live in static/images/entity. + +export interface AnonymousEntityIcon { + /** The entity icon's curated default background color (hex). */ + color: string; + /** Inner SVG markup (no outer /). Any "%COLOR%" token is the icon's + * color, substitutable at render time so a custom "bg" recolors accents too. */ + markup: string; +} + +export const ANONYMOUS_ENTITY_ICONS: Record = { + anchor: { + color: '#D6455C', + markup: + '\n\n\n', + }, + antenna: { + color: '#8A9A3B', + markup: + '\n\n', + }, + anvil: { + color: '#B5522C', + markup: + '\n\n\n', + }, + arch: { + color: '#E0348E', + markup: + '', + }, + bridge: { + color: '#B5892C', + markup: + '\n\n\n', + }, + chevron: { + color: '#C99A2E', + markup: + '\n', + }, + circuit_node: { + color: '#5B7A9E', + markup: + '\n\n\n\n\n', + }, + compass: { + color: '#4FA33F', + markup: + '\n', + }, + cube: { + color: '#2E9E9E', + markup: + '\n\n', + }, + diamond: {color: '#E8622C', markup: ''}, + dome: { + color: '#D6822C', + markup: + '\n\n', + }, + gate: { + color: '#3B7DD8', + markup: '', + }, + hexagon: {color: '#3B7DD8', markup: ''}, + key: { + color: '#C99A2E', + markup: + '\n\n\n', + }, + lighthouse: { + color: '#D6455C', + markup: + '\n\n\n', + }, + lock: { + color: '#E0348E', + markup: + '\n\n', + }, + obelisk: {color: '#5B6BC0', markup: ''}, + octagon: { + color: '#B5522C', + markup: '', + }, + orbit_ring: { + color: '#4FA33F', + markup: + '\n', + }, + parallelogram: {color: '#2E8F5B', markup: ''}, + pavilion: { + color: '#4FA33F', + markup: + '\n\n', + }, + pentagon: {color: '#7B3F9E', markup: ''}, + plus_facet: { + color: '#E0348E', + markup: + '', + }, + silo: { + color: '#8A6A2E', + markup: + '\n\n', + }, + spiral: { + color: '#8A9A3B', + markup: + '', + }, + spire: { + color: '#7B3F9E', + markup: + '\n', + }, + star: { + color: '#5B6BC0', + markup: '', + }, + tower: { + color: '#C23B7A', + markup: + '\n\n\n\n', + }, + townhouse: { + color: '#5B7A9E', + markup: + '\n\n\n', + }, + triangle_stack: { + color: '#D6455C', + markup: + '\n', + }, + turbine: { + color: '#7B3F9E', + markup: + '\n', + }, + valve: { + color: '#D68A2C', + markup: + '\n', + }, + windmill: { + color: '#2E9E9E', + markup: + '\n\n', + }, +}; diff --git a/packages/browser/src/utils/extractAvatarParamsFromUri.ts b/packages/browser/src/utils/extractAvatarParamsFromUri.ts new file mode 100644 index 0000000..b96262a --- /dev/null +++ b/packages/browser/src/utils/extractAvatarParamsFromUri.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import isAvatarUri, {AVATAR_URI_SCHEME} from './isAvatarUri'; + +export type AvatarShape = 'circle' | 'rounded'; + +export type AvatarVariant = 'anonymous_animal' | 'anonymous_entity' | 'blank' | 'one_letter' | 'two_letter'; + +export interface AvatarParams { + /** + * Explicit flat background color override (e.g. `#FF5733`). When set, replaces the + * derived gradient (letter variants) or the curated icon color (`anonymous_animal`/ + * `anonymous_entity`). + */ + bg?: string; + /** + * Gradient rotation index for letter variants (ignored by `anonymous_animal`/`anonymous_entity`). + */ + colors: number; + /** + * The rendered payload: pre-computed initials (e.g. `"BM"`) for `one_letter`/`two_letter`, + * a lowercase animal key (e.g. `"jackalope"`) for `anonymous_animal`, a lowercase entity key + * (e.g. `"hexagon"`) for `anonymous_entity`, or unused (empty) for `blank`. + */ + content: string; + /** + * Background shape. + */ + shape: AvatarShape; + /** + * What's drawn on the background. + */ + variant: AvatarVariant; +} + +const DEFAULT_PARAMS: AvatarParams = {colors: 0, content: '', shape: 'rounded', variant: 'two_letter'}; + +/** + * Parses the comma-separated `key=value` pairs of an `avatar:` URI, e.g. + * `"avatar:shape=circle,variant=two_letter,content=BM,colors=2,bg=#FF5733"`. + * + * @param uri - A URI string in the form `"avatar:shape=...,variant=...,content=...,colors=...,bg=..."`. + * @returns The parsed avatar parameters, falling back to defaults for any missing/invalid field. + * + * @example + * ```typescript + * extractAvatarParamsFromUri("avatar:shape=circle,variant=anonymous_animal,content=jackalope"); + * // { shape: "circle", variant: "anonymous_animal", content: "jackalope", colors: 0 } + * ``` + */ +const extractAvatarParamsFromUri = (uri: string): AvatarParams => { + if (!isAvatarUri(uri)) { + return {...DEFAULT_PARAMS}; + } + + const raw: string = uri.slice(AVATAR_URI_SCHEME.length); + const params: Record = {}; + + raw.split(',').forEach((pair: string) => { + const [key, ...rest] = pair.split('='); + if (key) { + params[key.trim()] = rest.join('=').trim(); + } + }); + + const shape: AvatarShape = ['circle', 'rounded'].includes(params['shape']) + ? (params['shape'] as AvatarShape) + : DEFAULT_PARAMS.shape; + const variant: AvatarVariant = ['anonymous_animal', 'anonymous_entity', 'blank', 'one_letter', 'two_letter'].includes( + params['variant'], + ) + ? (params['variant'] as AvatarVariant) + : DEFAULT_PARAMS.variant; + const colors: number = Number.parseInt(params['colors'], 10); + + return { + ...(params['bg'] ? {bg: params['bg']} : {}), + colors: Number.isFinite(colors) ? colors : DEFAULT_PARAMS.colors, + content: params['content'] ?? DEFAULT_PARAMS.content, + shape, + variant, + }; +}; + +export default extractAvatarParamsFromUri; diff --git a/packages/browser/src/utils/generateAvatarDataUri.ts b/packages/browser/src/utils/generateAvatarDataUri.ts new file mode 100644 index 0000000..a97f786 --- /dev/null +++ b/packages/browser/src/utils/generateAvatarDataUri.ts @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ANONYMOUS_ANIMAL_ICONS} from './anonymousAnimalIcons.generated'; +import {ANONYMOUS_ENTITY_ICONS} from './anonymousEntityIcons.generated'; +import type {AvatarParams} from './extractAvatarParamsFromUri'; +import hashStr from './hashStr'; +import pickAnonymousAvatarName from './pickAnonymousAvatarName'; +import pickAnonymousEntityName from './pickAnonymousEntityName'; + +// Curated on-brand gradient pairs — `colors` rotates through this set. +const AVATAR_PALETTES: [string, string][] = [ + ['#FF7300', '#EF4223'], + ['#3688FF', '#1d5eb4'], + ['#5567D5', '#8B6FE8'], + ['#06b6d4', '#0891b2'], + ['#10b981', '#059669'], + ['#ec4899', '#be185d'], + ['#f59e0b', '#ea580c'], + ['#8b5cf6', '#6d28d9'], + ['#5CD1FF', '#3688FF'], + ['#ef4444', '#b91c1c'], +]; + +/** + * Number of distinct gradient rotations `colors` cycles through for the `blank`, + * `one_letter`, and `two_letter` variants. Callers that offer a "pick a background" UI + * (e.g. a shuffleable swatch grid) can sample `colors` indices in `[0, AVATAR_GRADIENT_COUNT)` + * without needing the actual palette values. + */ +export const AVATAR_GRADIENT_COUNT: number = AVATAR_PALETTES.length; + +/** + * Fills in `params.content` when the spec itself didn't carry one, deriving it from a raw + * seed name (e.g. an app or user display name) — extracting initials for letter variants, + * or hash-picking a curated animal key for `anonymous_animal`. + * + * Callers that already have a final `content` value (pre-computed initials, or an exact + * animal key) should skip this and pass it directly instead. + * + * @param variant - The avatar variant `content` is being derived for. + * @param seedText - A raw name/seed to derive `content` from. + * @returns A ready-to-render `content` value. + * + * @example + * ```typescript + * deriveAvatarContent('two_letter', 'Jane Doe'); // "JD" + * deriveAvatarContent('anonymous_animal', 'session-abc123'); // "otter" + * deriveAvatarContent('anonymous_entity', 'app-abc123'); // "hexagon" + * deriveAvatarContent('blank', 'Jane Doe'); // "" + * ``` + */ +export const deriveAvatarContent = (variant: AvatarParams['variant'], seedText: string): string => { + if (variant === 'anonymous_animal') { + return pickAnonymousAvatarName(seedText || undefined); + } + if (variant === 'anonymous_entity') { + return pickAnonymousEntityName(seedText || undefined); + } + if (variant === 'blank') { + return ''; + } + + const letterCount: 1 | 2 = variant === 'one_letter' ? 1 : 2; + return (seedText.match(/[A-Za-z0-9]/g) ?? ['A']).slice(0, letterCount).join('').toUpperCase(); +}; + +/** + * Renders the 100x100 background shape, filled either with a gradient/flat-color reference. + */ +const shapeMarkup = (shape: AvatarParams['shape'], fill: string): string => + shape === 'circle' + ? `` + : ``; + +/** + * Renders a Vercel-style gradient avatar: a rounded/circle swatch, either bare (`blank`) or + * topped with initials (`one_letter`/`two_letter`). `content` is assumed final (already the + * initials to display, or empty for `blank`) — pass it through {@link deriveAvatarContent} + * first if it needs deriving. + */ +const generateGradientAvatar = ({shape, variant, content, colors, bg}: AvatarParams): string => { + let background: string; + let defs = ''; + if (bg) { + background = bg; + } else { + const h: number = hashStr(content || 'App'); + const idx: number = (((h + colors) % AVATAR_PALETTES.length) + AVATAR_PALETTES.length) % AVATAR_PALETTES.length; + const [c1, c2] = AVATAR_PALETTES[idx]; + const angle: number = ((((h >> 4) + colors * 37) % 360) + 360) % 360; + defs = ``; + background = 'url(#g)'; + } + + let text = ''; + if (variant !== 'blank') { + const letterCount: 1 | 2 = variant === 'one_letter' ? 1 : 2; + const initials: string = (content || 'A').toUpperCase().slice(0, letterCount); + const fontSize: number = letterCount === 1 ? 46 : 34; + text = `${initials}`; + } + + const svg = `${defs}${shapeMarkup(shape, background)}${text}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}; + +/** + * Renders one of the curated anonymous-animal icons, applying the requested background + * shape and (optionally) recoloring the whole icon with `bg`. `content` is assumed to + * already be a valid animal key — pass it through {@link deriveAvatarContent} first if + * it needs deriving, or if it might be invalid/empty. + */ +const generateAnonymousAnimalAvatar = ({shape, content, bg}: AvatarParams): string => { + const key: string = Object.prototype.hasOwnProperty.call(ANONYMOUS_ANIMAL_ICONS, content) + ? content + : pickAnonymousAvatarName(); + const icon = ANONYMOUS_ANIMAL_ICONS[key]; + const color: string = bg ?? icon.color; + const markup: string = icon.markup.replaceAll('%COLOR%', color); + const svg = `${shapeMarkup(shape, color)}${markup}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}; + +/** + * Renders one of the curated anonymous-entity icons (application/organization/resource-server + * shapes), applying the requested background shape and (optionally) recoloring the whole icon + * with `bg`. `content` is assumed to already be a valid entity key — pass it through + * {@link deriveAvatarContent} first if it needs deriving, or if it might be invalid/empty. + */ +const generateAnonymousEntityAvatar = ({shape, content, bg}: AvatarParams): string => { + const key: string = Object.prototype.hasOwnProperty.call(ANONYMOUS_ENTITY_ICONS, content) + ? content + : pickAnonymousEntityName(); + const icon = ANONYMOUS_ENTITY_ICONS[key]; + const color: string = bg ?? icon.color; + const markup: string = icon.markup.replaceAll('%COLOR%', color); + const svg = `${shapeMarkup(shape, color)}${markup}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}; + +/** + * Generates an avatar image as an `svg+xml` data URI from a fully-resolved {@link AvatarParams}. + * `params.content` is assumed final; use {@link deriveAvatarContent} first if it might be + * empty or otherwise needs deriving from a raw seed name. + * + * @param params - Resolved avatar parameters (see {@link AvatarParams}). + * @returns A `data:image/svg+xml,...` URI rendering the generated avatar. + * + * @example + * ```typescript + * generateAvatarDataUri({shape: 'circle', variant: 'two_letter', content: 'BM', colors: 2}); + * generateAvatarDataUri({shape: 'rounded', variant: 'anonymous_animal', content: 'jackalope'}); + * generateAvatarDataUri({shape: 'rounded', variant: 'anonymous_entity', content: 'hexagon'}); + * generateAvatarDataUri({shape: 'circle', variant: 'blank', content: '', colors: 5}); + * ``` + */ +const generateAvatarDataUri = (params: AvatarParams): string => { + if (params.variant === 'anonymous_animal') { + return generateAnonymousAnimalAvatar(params); + } + if (params.variant === 'anonymous_entity') { + return generateAnonymousEntityAvatar(params); + } + return generateGradientAvatar(params); +}; + +export default generateAvatarDataUri; diff --git a/packages/browser/src/utils/hashStr.ts b/packages/browser/src/utils/hashStr.ts new file mode 100644 index 0000000..dbbd0d7 --- /dev/null +++ b/packages/browser/src/utils/hashStr.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * 32-bit unsigned polynomial hash (`h = h*31 + charCode`), used to deterministically derive + * avatar colors/icons from a seed string. Shared by every avatar util that needs the same + * hash so seeds map identically across them. + * + * @example + * ```typescript + * hashStr('session-abc123'); + * ``` + */ +const hashStr = (str: string): number => { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (h * 31 + str.charCodeAt(i)) >>> 0; + } + return h; +}; + +export default hashStr; diff --git a/packages/browser/src/utils/isAvatarUri.ts b/packages/browser/src/utils/isAvatarUri.ts new file mode 100644 index 0000000..d71e6df --- /dev/null +++ b/packages/browser/src/utils/isAvatarUri.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const AVATAR_URI_SCHEME = 'avatar:'; + +/** + * Checks whether a given URI uses the `avatar:` scheme + * (e.g. `"avatar:style=rounded,text=Acme,colors=2"`). + * + * @param uri - The URI string to check. + * @returns `true` if the URI starts with `"avatar:"`, `false` otherwise. + * + * @example + * ```typescript + * isAvatarUri("avatar:style=circle,text=Acme,colors=0"); // true + * isAvatarUri("emoji:🐯"); // false + * ``` + */ +const isAvatarUri = (uri: string): boolean => typeof uri === 'string' && uri.startsWith(AVATAR_URI_SCHEME); + +export default isAvatarUri; diff --git a/packages/browser/src/utils/pickAnonymousAvatarName.ts b/packages/browser/src/utils/pickAnonymousAvatarName.ts new file mode 100644 index 0000000..4fa708f --- /dev/null +++ b/packages/browser/src/utils/pickAnonymousAvatarName.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ANONYMOUS_ANIMAL_ICONS} from './anonymousAnimalIcons.generated'; +import hashStr from './hashStr'; + +const ANONYMOUS_AVATAR_NAMES: string[] = Object.keys(ANONYMOUS_ANIMAL_ICONS).sort(); + +/** + * Deterministically picks one of the curated anonymous-avatar animal keys for a given seed + * (e.g. a session or device identifier), so the same seed always maps to the same avatar. + * + * Use this to derive the `content` value for an `avatar:variant=anonymous_animal` spec — + * the spec parser itself does no hashing, it just looks up whatever key it's given. + * + * @param seed - Seed text the pick is derived from. Falls back to a random pick when omitted. + * @returns An anonymous animal key (e.g. `"otter"`) usable as `avatar:` spec's `content` param. + * + * @example + * ```typescript + * pickAnonymousAvatarName('session-abc123'); // "otter" + * `avatar:variant=anonymous_animal,content=${pickAnonymousAvatarName(sessionId)}`; + * ``` + */ +const pickAnonymousAvatarName = (seed?: string): string => { + const index: number = seed + ? hashStr(seed) % ANONYMOUS_AVATAR_NAMES.length + : Math.floor(Math.random() * ANONYMOUS_AVATAR_NAMES.length); + + return ANONYMOUS_AVATAR_NAMES[index]; +}; + +export default pickAnonymousAvatarName; diff --git a/packages/browser/src/utils/pickAnonymousEntityName.ts b/packages/browser/src/utils/pickAnonymousEntityName.ts new file mode 100644 index 0000000..6218222 --- /dev/null +++ b/packages/browser/src/utils/pickAnonymousEntityName.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ANONYMOUS_ENTITY_ICONS} from './anonymousEntityIcons.generated'; +import hashStr from './hashStr'; + +const ANONYMOUS_ENTITY_NAMES: string[] = Object.keys(ANONYMOUS_ENTITY_ICONS).sort(); + +/** + * Deterministically picks one of the curated anonymous-entity icon keys for a given seed + * (e.g. an application, organization, or resource server identifier), so the same seed + * always maps to the same icon. + * + * Use this to derive the `content` value for an `avatar:variant=anonymous_entity` spec — + * the spec parser itself does no hashing, it just looks up whatever key it's given. + * + * @param seed - Seed text the pick is derived from. Falls back to a random pick when omitted. + * @returns An anonymous entity key (e.g. `"hexagon"`) usable as `avatar:` spec's `content` param. + * + * @example + * ```typescript + * pickAnonymousEntityName('app-abc123'); // "hexagon" + * `avatar:variant=anonymous_entity,content=${pickAnonymousEntityName(appId)}`; + * ``` + */ +const pickAnonymousEntityName = (seed?: string): string => { + const index: number = seed + ? hashStr(seed) % ANONYMOUS_ENTITY_NAMES.length + : Math.floor(Math.random() * ANONYMOUS_ENTITY_NAMES.length); + + return ANONYMOUS_ENTITY_NAMES[index]; +}; + +export default pickAnonymousEntityName; diff --git a/packages/browser/src/utils/resolveLogoUri.ts b/packages/browser/src/utils/resolveLogoUri.ts new file mode 100644 index 0000000..9b1c10b --- /dev/null +++ b/packages/browser/src/utils/resolveLogoUri.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {extractEmojiFromUri, isEmojiUri} from '@thunderid/javascript'; +import extractAvatarParamsFromUri, {type AvatarParams} from './extractAvatarParamsFromUri'; +import generateAvatarDataUri, {deriveAvatarContent} from './generateAvatarDataUri'; +import isAvatarUri from './isAvatarUri'; + +export type ResolvedLogoKind = 'avatar' | 'emoji' | 'url'; + +export interface ResolvedLogo { + /** + * Rendered image source (a data URI or plain URL). Absent when `kind` is `"emoji"`. + */ + imgSrc?: string; + /** + * The emoji glyph to render. Only present when `kind` is `"emoji"`. + */ + glyph?: string; + /** + * Which logo spec variant was resolved. + */ + kind: ResolvedLogoKind; +} + +/** + * Resolves an application logo spec string — `emoji:`, `avatar:shape=...,variant=..., + * content=...,colors=...,bg=...`, or a bare URL — into a renderable representation. + * + * A spec in none of the recognized schemes falls back to treating it as a plain URL so + * callers can always render *something* without special-casing. + * + * @param spec - The stored logo spec string. + * @param fallbackSeedText - Raw seed name (e.g. an app or user display name) used to derive + * an `avatar:` spec's `content` when the spec itself doesn't carry one (e.g. an app name + * to keep the avatar in sync as it changes). + * @returns The resolved logo, ready to render. + * + * @example + * ```typescript + * resolveLogoUri("emoji:🛡️"); // { kind: "emoji", glyph: "🛡️" } + * resolveLogoUri("avatar:shape=circle,variant=two_letter,content=BM"); // { kind: "avatar", imgSrc: "data:image/svg+xml,..." } + * resolveLogoUri("https://example.com/logo.png"); // { kind: "url", imgSrc: "https://example.com/logo.png" } + * ``` + */ +const resolveLogoUri = (spec: string, fallbackSeedText = ''): ResolvedLogo => { + if (isEmojiUri(spec)) { + return {glyph: extractEmojiFromUri(spec), kind: 'emoji'}; + } + + if (isAvatarUri(spec)) { + const params: AvatarParams = extractAvatarParamsFromUri(spec); + const content: string = params.content || deriveAvatarContent(params.variant, fallbackSeedText); + return {imgSrc: generateAvatarDataUri({...params, content}), kind: 'avatar'}; + } + + return {imgSrc: spec, kind: 'url'}; +}; + +export default resolveLogoUri; diff --git a/packages/browser/static/images/avatars/anonymous_animals/capybara.svg b/packages/browser/static/images/avatars/anonymous_animals/capybara.svg new file mode 100644 index 0000000..c6b7cd7 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/capybara.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/chameleon.svg b/packages/browser/static/images/avatars/anonymous_animals/chameleon.svg new file mode 100644 index 0000000..3c07cc8 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/chameleon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/chinchilla.svg b/packages/browser/static/images/avatars/anonymous_animals/chinchilla.svg new file mode 100644 index 0000000..3300627 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/chinchilla.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/chipmunk.svg b/packages/browser/static/images/avatars/anonymous_animals/chipmunk.svg new file mode 100644 index 0000000..46cb450 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/chipmunk.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/chupacabra.svg b/packages/browser/static/images/avatars/anonymous_animals/chupacabra.svg new file mode 100644 index 0000000..c0c2162 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/chupacabra.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/dingo.svg b/packages/browser/static/images/avatars/anonymous_animals/dingo.svg new file mode 100644 index 0000000..fc194ee --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/dingo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/dinosaur.svg b/packages/browser/static/images/avatars/anonymous_animals/dinosaur.svg new file mode 100644 index 0000000..d9781de --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/dinosaur.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/frog.svg b/packages/browser/static/images/avatars/anonymous_animals/frog.svg new file mode 100644 index 0000000..ba83100 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/frog.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/giraffe.svg b/packages/browser/static/images/avatars/anonymous_animals/giraffe.svg new file mode 100644 index 0000000..b8cf641 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/giraffe.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/hedgehog.svg b/packages/browser/static/images/avatars/anonymous_animals/hedgehog.svg new file mode 100644 index 0000000..8b5b9d9 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/hedgehog.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/hippo.svg b/packages/browser/static/images/avatars/anonymous_animals/hippo.svg new file mode 100644 index 0000000..043d22e --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/hippo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/jackal.svg b/packages/browser/static/images/avatars/anonymous_animals/jackal.svg new file mode 100644 index 0000000..bec0f8d --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/jackal.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/jackalope.svg b/packages/browser/static/images/avatars/anonymous_animals/jackalope.svg new file mode 100644 index 0000000..9cecb7d --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/jackalope.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/mink.svg b/packages/browser/static/images/avatars/anonymous_animals/mink.svg new file mode 100644 index 0000000..64155dd --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/mink.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/otter.svg b/packages/browser/static/images/avatars/anonymous_animals/otter.svg new file mode 100644 index 0000000..3722ac2 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/otter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/platypus.svg b/packages/browser/static/images/avatars/anonymous_animals/platypus.svg new file mode 100644 index 0000000..29119db --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/platypus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/quagga.svg b/packages/browser/static/images/avatars/anonymous_animals/quagga.svg new file mode 100644 index 0000000..8104683 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/quagga.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/raccoon.svg b/packages/browser/static/images/avatars/anonymous_animals/raccoon.svg new file mode 100644 index 0000000..d387b3d --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/raccoon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_animals/skunk.svg b/packages/browser/static/images/avatars/anonymous_animals/skunk.svg new file mode 100644 index 0000000..ca58999 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_animals/skunk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/anchor.svg b/packages/browser/static/images/avatars/anonymous_entity/anchor.svg new file mode 100644 index 0000000..358856e --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/anchor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/antenna.svg b/packages/browser/static/images/avatars/anonymous_entity/antenna.svg new file mode 100644 index 0000000..57bab9e --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/antenna.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/anvil.svg b/packages/browser/static/images/avatars/anonymous_entity/anvil.svg new file mode 100644 index 0000000..b667542 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/anvil.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/arch.svg b/packages/browser/static/images/avatars/anonymous_entity/arch.svg new file mode 100644 index 0000000..2d6c76c --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/arch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/bridge.svg b/packages/browser/static/images/avatars/anonymous_entity/bridge.svg new file mode 100644 index 0000000..1962182 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/bridge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/chevron.svg b/packages/browser/static/images/avatars/anonymous_entity/chevron.svg new file mode 100644 index 0000000..ae7efc7 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/chevron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/circuit_node.svg b/packages/browser/static/images/avatars/anonymous_entity/circuit_node.svg new file mode 100644 index 0000000..55036d4 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/circuit_node.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/compass.svg b/packages/browser/static/images/avatars/anonymous_entity/compass.svg new file mode 100644 index 0000000..fcc33b5 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/compass.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/cube.svg b/packages/browser/static/images/avatars/anonymous_entity/cube.svg new file mode 100644 index 0000000..99e2242 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/cube.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/diamond.svg b/packages/browser/static/images/avatars/anonymous_entity/diamond.svg new file mode 100644 index 0000000..0822de8 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/diamond.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/dome.svg b/packages/browser/static/images/avatars/anonymous_entity/dome.svg new file mode 100644 index 0000000..a8b3e92 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/dome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/gate.svg b/packages/browser/static/images/avatars/anonymous_entity/gate.svg new file mode 100644 index 0000000..978dea1 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/gate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/hexagon.svg b/packages/browser/static/images/avatars/anonymous_entity/hexagon.svg new file mode 100644 index 0000000..8935d1b --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/hexagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/key.svg b/packages/browser/static/images/avatars/anonymous_entity/key.svg new file mode 100644 index 0000000..7bde29c --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/key.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/lighthouse.svg b/packages/browser/static/images/avatars/anonymous_entity/lighthouse.svg new file mode 100644 index 0000000..d19ee76 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/lighthouse.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/lock.svg b/packages/browser/static/images/avatars/anonymous_entity/lock.svg new file mode 100644 index 0000000..8653469 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/obelisk.svg b/packages/browser/static/images/avatars/anonymous_entity/obelisk.svg new file mode 100644 index 0000000..f88a76e --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/obelisk.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/octagon.svg b/packages/browser/static/images/avatars/anonymous_entity/octagon.svg new file mode 100644 index 0000000..2d290c0 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/octagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/orbit_ring.svg b/packages/browser/static/images/avatars/anonymous_entity/orbit_ring.svg new file mode 100644 index 0000000..46d3d91 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/orbit_ring.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/parallelogram.svg b/packages/browser/static/images/avatars/anonymous_entity/parallelogram.svg new file mode 100644 index 0000000..2dc3c31 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/parallelogram.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/pavilion.svg b/packages/browser/static/images/avatars/anonymous_entity/pavilion.svg new file mode 100644 index 0000000..12af972 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/pavilion.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/pentagon.svg b/packages/browser/static/images/avatars/anonymous_entity/pentagon.svg new file mode 100644 index 0000000..6600d29 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/pentagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/plus_facet.svg b/packages/browser/static/images/avatars/anonymous_entity/plus_facet.svg new file mode 100644 index 0000000..0216756 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/plus_facet.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/silo.svg b/packages/browser/static/images/avatars/anonymous_entity/silo.svg new file mode 100644 index 0000000..809e627 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/silo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/spiral.svg b/packages/browser/static/images/avatars/anonymous_entity/spiral.svg new file mode 100644 index 0000000..8c61b0c --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/spiral.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/spire.svg b/packages/browser/static/images/avatars/anonymous_entity/spire.svg new file mode 100644 index 0000000..dae86c1 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/spire.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/star.svg b/packages/browser/static/images/avatars/anonymous_entity/star.svg new file mode 100644 index 0000000..0a61cb8 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/tower.svg b/packages/browser/static/images/avatars/anonymous_entity/tower.svg new file mode 100644 index 0000000..033cebb --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/tower.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/townhouse.svg b/packages/browser/static/images/avatars/anonymous_entity/townhouse.svg new file mode 100644 index 0000000..532d403 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/townhouse.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/triangle_stack.svg b/packages/browser/static/images/avatars/anonymous_entity/triangle_stack.svg new file mode 100644 index 0000000..fc3e43d --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/triangle_stack.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/turbine.svg b/packages/browser/static/images/avatars/anonymous_entity/turbine.svg new file mode 100644 index 0000000..884f8c0 --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/turbine.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/valve.svg b/packages/browser/static/images/avatars/anonymous_entity/valve.svg new file mode 100644 index 0000000..72a9cbd --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/valve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/browser/static/images/avatars/anonymous_entity/windmill.svg b/packages/browser/static/images/avatars/anonymous_entity/windmill.svg new file mode 100644 index 0000000..8ac4c6d --- /dev/null +++ b/packages/browser/static/images/avatars/anonymous_entity/windmill.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png b/packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png new file mode 100644 index 0000000000000000000000000000000000000000..47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff GIT binary patch literal 2082 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

KT=a_ssj|k9# zV|LklG(nsg;0s;cR|b)dP58*qzV%JfH83zV6s|~sJpKLQX?nbIfU)l8CZl>^7Uhb7 zJl_S7ljh!8;X~gP(vGIn<_s+$G*NVPAq&nS2?5&1htBSx?gV3{}pm2gN|W!06S9+FH@TmpaUF4UbxE z9B7;1>NOmS`BY@8YLCPFKdEnLK3HI=9d}|q2Tjk?QfBgHBtt0 zu>>U@yYJe8I`~FO;}U>kz!d17sWYCtZguHkuWC!-A z2xmy#;`paepJWUfZEY>S7{U1*c3#Ay@d1>WSVs4#QAo)Q;wZdurZxN9)9@0kfVZT%;uzZdbH%=w3y_7**g~z|F(f1~&^rYw) zGGp%}wI4kqMF(}RX?LCD33zSsP%q_szICwrmqMQ;7KvpFheOG59&$ry#k1lA*0O;c zCam4uH1=X|7V8P{tz<()6MxP<=3m?n#7$*oR$Ye}l7o9i$4afvT3C2Q=9=q-``$&Y zG@|MWAbx&X8R+PCHLO~uFbTn{n?V9^o4(7cu`aU`gdc{-A&c|(XHKVRCe8N9H~9S7 z-D%0fS1i_fXk4kakoP+)sX<9t_5d`5$vDkL0Nkw{g1(R!yzxd1P(6l*fN%+neg_AaMb;KLhFAZxq{ zflVW!0|tCJ>56!+6EQ~OY3wAqkllWCiD*s7h!34bqtQC%;q{=k$LOGwa7h~7TkLl0 zMBxJPBPt_DhjDhuo8o5FqrICND|{S9@OZH}xceZ-af}$&{tLy@$`l$)VISc{9A!V@ z8q7%bpznH3j7Dvo=IV_X28)(eHLc@5_Z=<%sI9FnRR6fT+MuhhhhFZ-5)&9m~MMD@Ox7gmEHA! zdjU1K8YX)3SlxGc(MMX!-g4S}#*zr5aQ)I}B}5$0f>iY0G9s?$%3?zXErB}ow*aoW zBX6d~QyHDJvE8tr$>{sCFz4H(bn=cwgwI&EE~m1x0ybh*+H12GeAD_%$C=`nu0I}7 zzW+laQ6~)rLDm#0J~R&ZFkoyVwUDSwOrs@MYnuQRf#+>CtHDQShi!p24HQZmxt9Uw z4{)oZ#Nk7WY|}qswQ&ABp?ob7hdq7sgWQLzg%Zn7s4%i9nBy@AAiLtTsU-?7o zeJy}L$9NS6H5EBZ@HL^(kMv%&bssrxLHZi^-kJ{~8HzuW!F z>4aVmKZ;V@({tqqz2OZuwz830h zp}yklKQVj1BIPSmz9Qu-QvTOX(0`Ky9iyhAqEtd!qqMhHTHpRXzpAPn4kM-JYmjbl QMO3a|b-Yr0`EK&R0oMU`#Q*>R literal 0 HcmV?d00001 diff --git a/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png b/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png new file mode 100644 index 0000000000000000000000000000000000000000..47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff GIT binary patch literal 2082 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1N(JP7srr_Id3jA@-i53 z95DEFXMgI4ja%e?m6tbcF?X2z{K9j~FO__?^PK08GDpKv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX = ({component}: AdapterProps) => { return null; } - if (isEmojiUri(src)) { + const resolvedIcon: ResolvedLogo = resolveLogoUri(src, alt); + + if (resolvedIcon.kind === 'emoji') { // Bare numbers (e.g. "48") are valid for width/height attributes but // are unit-less and ignored as CSS properties — normalize them to px. const toCSSLength = (value: string): string => (/^\d+(\.\d+)?$/.test(value) ? `${value}px` : value); @@ -83,7 +85,7 @@ const ImageComponent: FC = ({component}: AdapterProps) => { }} > - {extractEmojiFromUri(src)} + {resolvedIcon.glyph} @@ -93,7 +95,7 @@ const ImageComponent: FC = ({component}: AdapterProps) => { return (

{alt} ( + {ui} +); + +const buildProps = (config: Record): AdapterProps => ({ + component: {config} as unknown as EmbeddedFlowComponent, + formErrors: {}, + formValues: {}, + isFormValid: true, + isLoading: false, + onInputChange: vi.fn(), + touchedFields: {}, +}); + +describe('ImageComponent', () => { + afterEach(() => { + cleanup(); + }); + + it('renders a plain image URL as-is', () => { + render(withTheme()); + const img: HTMLElement = screen.getByAltText('Acme'); + expect(img).toHaveAttribute('src', 'https://example.com/logo.png'); + }); + + it('renders an emoji: spec as its glyph, not an image', () => { + const {container} = render(withTheme()); + expect(screen.getByText('🐼')).toBeInTheDocument(); + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('renders an anonymous_animal avatar: spec using the bundled icon markup', () => { + const [name] = Object.keys(ANONYMOUS_ANIMAL_ICONS); + render( + withTheme( + , + ), + ); + const img: HTMLElement = screen.getByAltText('Anonymous'); + expect(decodeURIComponent(img.getAttribute('src') ?? '')).toContain(ANONYMOUS_ANIMAL_ICONS[name].color); + }); +}); diff --git a/packages/react/src/components/adapters/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png b/packages/react/src/components/adapters/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png new file mode 100644 index 0000000000000000000000000000000000000000..539d54cdacbe86d7fb29c1941c4e962734c45fd2 GIT binary patch literal 6923 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

KT=a_ssj|k9# zV|LklG(nsg;0s;cR|b)dP58*qzV%JfH83zV6s|~sJpKLQX?nbIfU)l8CZl>^7Uhb7 zJl_S7ljh!8;X~gP(vGIn<_s+$G*NVPAq&nS2?5&1htBSx?gV3{}pm2gN|W!06S9+FH@TmpaUF4UbxE z9B7;1>NOmS`BY@8YLCPFKdEnLK3HI=9d}|q2Tjk?QfBgHBtt0 zu>>U@yYJe8I`~FO;}U>kz!d17sWYCtZguHkuWC!-A z2xmy#;`paepJWUfZEY>S7{U1*c3#Ay@d1>WSVs4#QAo)Q;wZdurZxN9)9@0kfVZT%;uzZdbH%=w3y_7**g~z|F(f1~&^rYw) zGGp%}wI4kqMF(}RX?LCD33zSsP%q_szICwrmqMQ;7KvpFheOG59&$ry#k1lA*0O;c zCam4uH1=X|7V8P{tz<()6MxP<=3m?n#7$*oR$Ye}l7o9i$4afvT3C2Q=9=q-``$&Y zG@|MWAbx&X8R+PCHLO~uFbTn{n?V9^o4(7cu`aU`gdc{-A&c|(XHKVRCe8N9H~9S7 z-D%0fS1i_fXk4kakoP+)sX<9t_5d`5$vDkL0Nkw{g1(R!yzxd1P(6l*fN%+neg_AaMb;KLhFAZxq{ zflVW!0|tCJ>56!+6EQ~OY3wAqkllWCiD*s7h!34bqtQC%;q{=k$LOGwa7h~7TkLl0 zMBxJPBPt_DhjDhuo8o5FqrICND|{S9@OZH}xceZ-af}$&{tLy@$`l$)VISc{9A!V@ z8q7%bpznH3j7Dvo=IV_X28)(eHLc@5_Z=<%sI9FnRR6fT+MuhhhhFZ-5)&9m~MMD@Ox7gmEHA! zdjU1K8YX)3SlxGc(MMX!-g4S}#*zr5aQ)I}B}5$0f>iY0G9s?$%3?zXErB}ow*aoW zBX6d~QyHDJvE8tr$>{sCFz4H(bn=cwgwI&EE~m1x0ybh*+H12GeAD_%$C=`nu0I}7 zzW+laQ6~)rLDm#0J~R&ZFkoyVwUDSwOrs@MYnuQRf#+>CtHDQShi!p24HQZmxt9Uw z4{)oZ#Nk7WY|}qswQ&ABp?ob7hdq7sgWQLzg%Zn7s4%i9nBy@AAiLtTsU-?7o zeJy}L$9NS6H5EBZ@HL^(kMv%&bssrxLHZi^-kJ{~8HzuW!F z>4aVmKZ;V@({tqqz2OZuwz830h zp}yklKQVj1BIPSmz9Qu-QvTOX(0`Ky9iyhAqEtd!qqMhHTHpRXzpAPn4kM-JYmjbl QMO3a|b-Yr0`EK&R0oMU`#Q*>R literal 0 HcmV?d00001 diff --git a/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png b/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png new file mode 100644 index 0000000000000000000000000000000000000000..47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff GIT binary patch literal 2082 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1N(JP7srr_Id3jA@-i53 z95DEFXMgI4ja%e?m6tbcF?X2z{K9j~FO__?^PK08GDpKv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvXv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX { * @example * ```tsx * + * * ``` */ -const BaseUserAvatar: FC = ({user, size = 40, className}: BaseUserAvatarProps): JSX.Element => { - const imageUrl: string | undefined = user ? resolvePicture(user) : undefined; +const BaseUserAvatar: FC = ({ + user, + size = 40, + className, + anonymousAvatarSeed = undefined, +}: BaseUserAvatarProps): JSX.Element => { + const imageUrl: string | undefined = useMemo(() => { + if (user) return resolvePicture(user); + return `avatar:variant=anonymous_animal,content=${pickAnonymousAvatarName(anonymousAvatarSeed)}`; + }, [user, anonymousAvatarSeed]); const name: string | undefined = user ? resolveName(user) : undefined; return ; diff --git a/packages/react/src/components/primitives/Avatar/Avatar.tsx b/packages/react/src/components/primitives/Avatar/Avatar.tsx index 04236d5..a658948 100644 --- a/packages/react/src/components/primitives/Avatar/Avatar.tsx +++ b/packages/react/src/components/primitives/Avatar/Avatar.tsx @@ -17,7 +17,7 @@ */ import {cx} from '@emotion/css'; -import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; +import {withVendorCSSClassPrefix, bem, resolveLogoUri, ResolvedLogo} from '@thunderid/browser'; import {FC, JSX, useMemo} from 'react'; import useStyles from './Avatar.styles'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -40,7 +40,8 @@ export interface AvatarProps { */ className?: string; /** - * The URL of the avatar image + * The avatar image. Accepts a plain URL, or any `resolveLogoUri`-compatible + * spec (`emoji:`, or `avatar:shape=...,variant=...,content=...,colors=...,bg=...`). */ imageUrl?: string; /** @@ -74,6 +75,13 @@ export const Avatar: FC = ({ }: AvatarProps): JSX.Element => { const {theme, colorScheme}: ReturnType = useTheme(); + const resolvedIcon: ResolvedLogo | undefined = useMemo( + () => (imageUrl ? resolveLogoUri(imageUrl, name ?? alt) : undefined), + [imageUrl, name, alt], + ); + + const hasImage: boolean = resolvedIcon?.kind !== 'emoji' && Boolean(resolvedIcon?.imgSrc); + const generateBackgroundColor = (inputString: string): string => { const hash: number = inputString.split('').reduce((acc: number, char: string) => { const charCode: number = char.charCodeAt(0); @@ -101,7 +109,7 @@ export const Avatar: FC = ({ }; const backgroundColor: string | undefined = useMemo(() => { - if (!name || imageUrl) { + if (!name || hasImage) { return undefined; } @@ -114,7 +122,7 @@ export const Avatar: FC = ({ } return background; - }, [background, name, imageUrl]); + }, [background, name, hasImage]); const styles: ReturnType = useStyles(theme, colorScheme, size, variant, backgroundColor); @@ -130,10 +138,18 @@ export const Avatar: FC = ({ .toUpperCase(); const renderContent = (): JSX.Element | string => { - if (imageUrl) { + if (resolvedIcon?.kind === 'emoji') { + return ( + + {resolvedIcon.glyph} + + ); + } + + if (hasImage) { return ( {alt} diff --git a/packages/react/src/components/primitives/Avatar/__tests__/Avatar.test.tsx b/packages/react/src/components/primitives/Avatar/__tests__/Avatar.test.tsx new file mode 100644 index 0000000..8c42b85 --- /dev/null +++ b/packages/react/src/components/primitives/Avatar/__tests__/Avatar.test.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {render, screen, cleanup} from '@testing-library/react'; +import {createTheme, ANONYMOUS_ANIMAL_ICONS} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; +import {Avatar} from '../Avatar'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const withTheme = (ui: ReactElement): ReactElement => ( + {ui} +); + +describe('Avatar', () => { + afterEach(() => { + cleanup(); + }); + + it('renders a plain image URL as-is', () => { + render(withTheme()); + const img: HTMLElement = screen.getByAltText('Acme'); + expect(img).toHaveAttribute('src', 'https://example.com/logo.png'); + }); + + it('renders an emoji: spec as its glyph, not an image', () => { + const {container} = render(withTheme()); + expect(screen.getByText('🐼')).toBeInTheDocument(); + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('renders an anonymous_animal avatar: spec using the bundled icon markup', () => { + const [name] = Object.keys(ANONYMOUS_ANIMAL_ICONS); + render( + withTheme(), + ); + const img: HTMLElement = screen.getByAltText('Anonymous'); + expect(decodeURIComponent(img.getAttribute('src') ?? '')).toContain(ANONYMOUS_ANIMAL_ICONS[name].color); + }); + + it('falls back to initials when only a name is provided', () => { + render(withTheme()); + expect(screen.getByText('AL')).toBeInTheDocument(); + }); + + it('renders the default icon when neither imageUrl nor name is provided', () => { + const {container} = render(withTheme()); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/primitives/Logo/Logo.styles.ts b/packages/react/src/components/primitives/Logo/Logo.styles.ts index baf7383..acf8f93 100644 --- a/packages/react/src/components/primitives/Logo/Logo.styles.ts +++ b/packages/react/src/components/primitives/Logo/Logo.styles.ts @@ -58,7 +58,34 @@ const useStyles = (theme: Theme, colorScheme: string, size: LogoSize): Record = { + large: css` + width: 64px; + height: 64px; + font-size: 36px; + `, + medium: css` + width: 48px; + height: 48px; + font-size: 28px; + `, + small: css` + width: 32px; + height: 32px; + font-size: 20px; + `, + }; + return { + emoji: baseEmoji, + emojiSize: emojiSizeStyles[size], large: largeLogo, logo: baseLogo, medium: mediumLogo, diff --git a/packages/react/src/components/primitives/Logo/Logo.tsx b/packages/react/src/components/primitives/Logo/Logo.tsx index a7bcac7..090ed19 100644 --- a/packages/react/src/components/primitives/Logo/Logo.tsx +++ b/packages/react/src/components/primitives/Logo/Logo.tsx @@ -17,7 +17,7 @@ */ import {cx} from '@emotion/css'; -import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; +import {withVendorCSSClassPrefix, bem, resolveLogoUri, ResolvedLogo} from '@thunderid/browser'; import {FC} from 'react'; import useStyles from './Logo.styles'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -62,19 +62,40 @@ const Logo: FC = ({className, src, alt, title, size = 'medium'}: Logo const logoConfig: Record | undefined = theme.images?.logo as Record | undefined; - const logoSrc: string | undefined = src || logoConfig?.['url']; + const logoSpec: string | undefined = src || logoConfig?.['url']; const logoAlt: string = alt || logoConfig?.['alt'] || 'Logo'; const logoTitle: string | undefined = title || logoConfig?.['title']; - if (!logoSrc) { + if (!logoSpec) { return null; } + const resolvedLogo: ResolvedLogo = resolveLogoUri(logoSpec, logoAlt); + + if (resolvedLogo.kind === 'emoji') { + return ( + + {resolvedLogo.glyph} + + ); + } + return ( {logoAlt} VNode { return (): VNode => { - const img: VNode = h('img', { - alt: props.alt, - class: withVendorCSSClassPrefix('logo__image'), - height: props.height, - src: props.src, - width: props.width, - }); + const resolvedLogo: ResolvedLogo | undefined = props.src ? resolveLogoUri(props.src, props.alt) : undefined; + + const img: VNode = + resolvedLogo?.kind === 'emoji' + ? h( + 'span', + { + 'aria-label': props.alt, + class: withVendorCSSClassPrefix('logo__emoji'), + height: props.height, + role: 'img', + width: props.width, + }, + [resolvedLogo.glyph], + ) + : h('img', { + alt: props.alt, + class: withVendorCSSClassPrefix('logo__image'), + height: props.height, + src: resolvedLogo?.imgSrc, + width: props.width, + }); if (props.href) { return h(