import { css, html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { BaseElement } from "../../core/base-element.ts";
import { oneOf } from "../../core/property-guards.ts";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarShape = "circle" | "square";
const avatarSizes = ["xs", "sm", "md", "lg", "xl"] as const;
const avatarShapes = ["circle", "square"] as const;
/**
* Returns true only when `src` is a `data:` URI — an inline, self-contained
* image with NO network fetch.
*
* The UI library forbids external resource loading (see packages/ui/README.md,
* "No External Resources"): `cf-avatar` runs on the trusted main thread and its
* `src` is often supplied by a sandboxed pattern, so rendering an `
` that
* points at `http(s):`, a protocol-relative `//host`, `blob:`, or any other
* remote/opaque scheme would silently fetch an external resource — a privacy /
* exfil beacon and tracking vector. Those values are therefore treated as NOT
* an image so the caller falls back to the inline glyph / initials path.
*
* Only `data:` URIs are inline and safe to render as an `
`. Anything else
* (emoji, initials text, relative paths, remote URLs) returns false.
*/
export const isAvatarImageUrl = (src: string): boolean =>
/^data:/i.test(src.trim());
/**
* Returns true when `src` looks like a URL / path / scheme rather than a typed
* glyph (emoji or a couple of letters). Such values are NOT fetched (only
* `data:` URIs render as images), and rendering the raw URL text inside the
* avatar would look broken — so they degrade to initials instead of the glyph
* path. Matches any `scheme:` prefix or leading `/` (covers `http(s):`, `blob:`,
* `ftp:`, protocol-relative `//host`, and root/relative paths), excluding the
* inline `data:` case handled by `isAvatarImageUrl`.
*/
export const isRemoteLikeSource = (src: string): boolean => {
const trimmed = src.trim();
return !/^data:/i.test(trimmed) &&
/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(trimmed);
};
/** Up to two uppercase initials derived from a display name. */
export const initialsForName = (name: string | undefined): string => {
if (!name) return "?";
const initials = name
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase())
.join("")
.slice(0, 2);
return initials || "?";
};
/**
* CFAvatar — generic avatar primitive: image, glyph, or initials fallback.
*
* Presentation only; it carries NO trust claims, so any code — including
* sandboxed user-space patterns — may render it. The trusted, profile-cell-bound
* identity presentation lives in ``, which composes this.
*
* Rendering precedence:
* 1. `src` that is a `data:` URI → `
` (falls back to initials on error)
* 2. a short typed glyph (emoji) → the glyph as-is
* 3. otherwise → initials derived from `name`.
* Remote URL/path-like `src` (http(s):, //host, blob:, /path, …) is never
* fetched and is not shown as raw text — it degrades to initials here
* (see `isAvatarImageUrl` / `isRemoteLikeSource`).
*
* @element cf-avatar
* @attr {string} src - A `data:` image URI, or a glyph/emoji string
* @attr {string} name - Display name; drives the initials fallback + alt text
* @attr {string} alt - Explicit alt text (defaults to `name`)
* @attr {string} size - xs | sm | md | lg | xl (default md)
* @attr {string} shape - circle | square (default circle)
*/
export class CFAvatar extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
:host {
--cf-avatar-size: 2rem;
--cf-avatar-font-size: 0.8125rem;
--cf-avatar-bg: var(--cf-theme-color-secondary, hsl(0, 0%, 92%));
--cf-avatar-fg: var(
--cf-theme-color-secondary-foreground,
hsl(0, 0%, 25%)
);
display: inline-block;
vertical-align: middle;
}
:host([size="xs"]) {
--cf-avatar-size: 1.25rem;
--cf-avatar-font-size: 0.5625rem;
}
:host([size="sm"]) {
--cf-avatar-size: 1.5rem;
--cf-avatar-font-size: 0.6875rem;
}
/* md is the default — no override */
:host([size="lg"]) {
--cf-avatar-size: 2.75rem;
--cf-avatar-font-size: 1rem;
}
:host([size="xl"]) {
--cf-avatar-size: 4rem;
--cf-avatar-font-size: 1.5rem;
}
.avatar {
box-sizing: border-box;
width: var(--cf-avatar-size);
height: var(--cf-avatar-size);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--cf-avatar-bg);
color: var(--cf-avatar-fg);
font-size: var(--cf-avatar-font-size);
font-weight: var(--cf-font-weight-semibold, 600);
line-height: 1;
user-select: none;
border-radius: var(--cf-border-radius-full, 9999px);
}
:host([shape="square"]) .avatar {
border-radius: var(--cf-border-radius-md, 6px);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.glyph {
font-size: calc(var(--cf-avatar-font-size) * 1.4);
}
`,
];
@property({ type: String })
accessor src: string | undefined = undefined;
@property({ type: String })
accessor name: string | undefined = undefined;
@property({ type: String })
accessor alt: string | undefined = undefined;
@property({ type: String, reflect: true })
accessor size: AvatarSize = "md";
@property({ type: String, reflect: true })
accessor shape: AvatarShape = "circle";
@state()
private accessor _imgError = false;
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
// A new src gets a fresh chance to load before we fall back to initials.
if (changed.has("src")) this._imgError = false;
if (changed.has("size")) this.size = oneOf(this.size, avatarSizes, "md");
if (changed.has("shape")) {
this.shape = oneOf(this.shape, avatarShapes, "circle");
}
}
override render() {
const src = (this.src ?? "").trim();
const name = this.name;
const showImage = src.length > 0 && isAvatarImageUrl(src) &&
!this._imgError;
// Short typed glyphs (emoji / a couple of letters) render as-is; URL/path
// -like sources are neither fetched nor shown as raw text — they fall
// through to initials.
const showGlyph = src.length > 0 && !showImage && !isRemoteLikeSource(src);
return html`
${showImage
? html`
`
: showGlyph
? html`
${src}
`
: html`
${initialsForName(name)}
`}
`;
}
private _onImgError = (): void => {
this._imgError = true;
};
}
declare global {
interface HTMLElementTagNameMap {
"cf-avatar": CFAvatar;
}
}