import { css, html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { consume } from "@lit/context";
import { BaseElement } from "../../core/base-element.ts";
import "../cf-avatar/index.ts";
import type { AvatarSize } from "../cf-avatar/cf-avatar.ts";
import {
type CellHandle,
type CfcLabelView,
NAME,
type RuntimeClient,
} from "@commonfabric/runtime-client";
import type { DID } from "@commonfabric/identity";
import {
appViewToUrlPath,
navigate,
preserveAppViewMode,
urlToAppView,
} from "@commonfabric/shell/shared";
import { runtimeContext, spaceContext } from "../../runtime-context.ts";
import { ownerPrincipalFromLabel } from "../../core/cfc-label.ts";
import { type IdentitySeal, identitySeal } from "./identity-seal.ts";
import {
registerSeal,
type SealLivenessClient,
unregisterSeal,
} from "./seal-liveness.ts";
/** Verification state of the presented identity. */
export type ProfileBadgeState = "presented" | "verified" | "unverified";
export type ProfileBadgeDisplay = {
name: string | undefined;
avatar: string | undefined;
};
/** Extra profile details surfaced in the badge hover/focus tooltip (CT-1648). */
export type ProfileBadgeTooltip = {
bio: string | undefined;
pinnedCount: number;
};
/**
* Extracts the tooltip details (bio + pinned-piece count) from a subscribed
* profile-cell value. `bio` is the owner-authored free-text description;
* `pinnedCount` is the number of profile `elements` (pinned pieces / cards).
* Both are best-effort: a badge bound to a derived projection (e.g. the
* self-view `{name, avatar}` cell) simply yields no bio and a zero count. Pure,
* so it is unit-testable without a runtime.
*/
export const profileTooltipFromValue = (val: unknown): ProfileBadgeTooltip => {
if (!val || typeof val !== "object") {
return { bio: undefined, pinnedCount: 0 };
}
const record = val as Record;
const rawBio = record["bio"];
const bio = typeof rawBio === "string" && rawBio.trim().length > 0
? rawBio.trim()
: undefined;
const elements = record["elements"];
const pinnedCount = Array.isArray(elements) ? elements.length : 0;
return { bio, pinnedCount };
};
/**
* Extracts the display name + avatar from a subscribed profile-cell value.
* Prefers the profile's own editable `name` field, falling back to the cell's
* `[NAME]`. This ordering matters: on `main`, profile-home sets `[NAME]` to the
* static placeholder `"Profile"` (profile-home.tsx:303), so trusting `[NAME]`
* first would render every profile as "Profile". The `name` field is the
* reliable display name; on the multi-profile branch `[NAME]` becomes the
* person's name and either source yields the same result. Pure, so it is
* unit-testable without a runtime.
*/
export const profileDisplayFromValue = (val: unknown): ProfileBadgeDisplay => {
if (!val || typeof val !== "object") {
return { name: undefined, avatar: undefined };
}
const record = val as Record;
const named = record[NAME as unknown as PropertyKey];
const plain = record["name"];
const avatar = record["avatar"];
const name = typeof plain === "string" && plain.trim().length > 0
? plain
: typeof named === "string" && named.trim().length > 0
? named
: undefined;
return {
name,
avatar: typeof avatar === "string" && avatar.trim().length > 0
? avatar
: undefined,
};
};
/**
* CFProfileBadge — official presentation of a profile (avatar + name).
*
* You bind it a *cell* containing a profile (e.g. `$profile={profileCell}`) and
* it renders the person's avatar + name as system chrome. It runs on the trusted
* main thread (outside the iframe sandbox where patterns run), which is what lets
* it draw an identity treatment a pattern cannot forge.
*
* When the bound cell carries a runtime-attested `represents-principal` CFC label
* (read over trusted IPC via `getCfcLabel()`), the badge enters the **verified**
* state and draws a *generative identity seal* — a deterministic aura derived
* purely from the owner's principal DID (see `identity-seal.ts`). Because the
* aura is a pure function of the DID, it is the *same everywhere* that person's
* badge appears, so it reads as a recognizable fingerprint of their identity;
* and because it is gated on the attestation (which user-space cannot mint), a
* pattern can mimic the CSS but not earn the verified seal for a DID it doesn't
* control. Without that label the badge stays in the plain "presented" state.
*
* @element cf-profile-badge
* @attr {string} size - avatar size: xs | sm | md | lg | xl (default md)
*/
export class CFProfileBadge extends BaseElement implements SealLivenessClient {
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: inline-block;
vertical-align: middle;
max-width: 100%;
}
.badge {
position: relative;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: 100%;
padding: 0.1875rem 0.625rem 0.1875rem 0.1875rem;
border-radius: var(--cf-border-radius-full, 9999px);
border: 1px solid var(--cf-theme-color-border, hsl(0, 0%, 89%));
background: var(--cf-theme-color-surface, hsl(0, 0%, 99%));
color: var(--cf-theme-color-text, hsl(0, 0%, 9%));
line-height: 1;
}
/* CT-1648: hover/focus tooltip surfacing the profile's configured bio +
pinned-piece count. Hidden until the badge is hovered or focused
(keyboard focus drives it on touch/AT). pointer-events:none so it never
intercepts the badge's own click/navigation. */
.tooltip {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
z-index: 30;
display: flex;
flex-direction: column;
gap: 0.25rem;
box-sizing: border-box;
width: max-content;
max-width: 18rem;
padding: 0.5rem 0.625rem;
border-radius: var(--cf-border-radius-md, 0.5rem);
border: 1px solid var(--cf-theme-color-border, hsl(0, 0%, 89%));
background: var(--cf-theme-color-surface, hsl(0, 0%, 99%));
color: var(--cf-theme-color-text, hsl(0, 0%, 9%));
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.28);
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition:
opacity 120ms ease-out,
transform 120ms ease-out,
visibility 120ms;
pointer-events: none;
text-align: left;
white-space: normal;
}
.badge:hover .tooltip,
.badge:focus-visible .tooltip,
.badge:focus-within .tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tooltip-name {
font-size: var(--cf-font-size-sm, 0.8125rem);
font-weight: var(--cf-font-weight-semibold, 600);
}
.tooltip-bio {
font-size: var(--cf-font-size-xs, 0.75rem);
line-height: 1.35;
color: var(--cf-theme-color-text-secondary, hsl(0, 0%, 40%));
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tooltip-meta {
font-size: var(--cf-font-size-xs, 0.75rem);
color: var(--cf-theme-color-muted-foreground, hsl(0, 0%, 45%));
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: none;
transform: none;
}
.badge:hover .tooltip,
.badge:focus-visible .tooltip,
.badge:focus-within .tooltip {
transform: none;
}
}
/* CT-1750: navigable badges (bound to a real profile cell) act as links.
The pointer is set on BOTH the host and the inner badge: the badge fills
the host's content box, but a flex/line-box can stretch the host past it,
and that uncovered host area would otherwise show the default arrow. */
:host([data-navigable]) {
cursor: pointer;
}
.badge[data-navigable] {
cursor: pointer;
}
.badge[data-navigable]:focus-visible {
outline: 2px solid var(--cf-theme-color-primary, hsl(212, 100%, 47%));
outline-offset: 2px;
}
.name {
font-size: var(--cf-font-size-sm, 0.8125rem);
font-weight: var(--cf-font-weight-semibold, 600);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16ch;
}
/* The generative aura ring. Transparent until the badge is verified; when
verified, a separate aura-ring layer carries the per-identity conic
gradient (a pure function of the owner DID) behind the avatar, so the ring
is unique-but-stable for each person. Keeping it on its own layer lets it
rotate in lockstep (the always-on animation below) and carry the shimmer
and cursor-sheen layers, all without rotating the avatar. */
.aura {
position: relative;
display: inline-flex;
flex: 0 0 auto;
border-radius: var(--cf-border-radius-full, 9999px);
padding: 0;
}
.aura-ring {
position: absolute;
inset: 0;
border-radius: var(--cf-border-radius-full, 9999px);
z-index: 0;
transform-origin: center;
}
/* Intrinsic shimmer — a soft light band that sweeps across the disc on a
shared clock (seeded per-badge via animation-delay so every verified seal
sweeps in lockstep). Keeps the seal visibly "alive" even when the cursor
is still, in a way ordinary chrome never is. Sits above the avatar so the
light glazes the face; screen-blended and contained by the aura's
isolation so it only lifts the badge, not the page behind it. */
.aura-glow {
position: absolute;
inset: 0;
border-radius: var(--cf-border-radius-full, 9999px);
z-index: 2;
pointer-events: none;
opacity: 0;
mix-blend-mode: screen;
background: linear-gradient(
115deg,
transparent 34%,
hsl(var(--seal-hue, 200) 95% 90% / 0.5) 47%,
rgba(255, 255, 255, 0.85) 50%,
hsl(var(--seal-hue, 200) 95% 90% / 0.5) 53%,
transparent 66%
);
background-size: 280% 280%;
}
/* Cursor sheen — a bright reflective hotspot that tracks the host cursor,
even when the cursor is nowhere near the badge. Driven by
--seal-mx/--seal-my/--seal-sheen-a/--seal-sheen-hue, which the shared
liveness controller sets on the host (they inherit into this layer). A
sandboxed pattern only sees pointer events inside its own iframe, so a
forgery cannot reproduce this. */
.aura-sheen {
position: absolute;
inset: 0;
border-radius: var(--cf-border-radius-full, 9999px);
z-index: 3;
pointer-events: none;
opacity: var(--seal-sheen-a, 0);
mix-blend-mode: screen;
background:
radial-gradient(
circle at var(--seal-mx, 50%) var(--seal-my, 50%),
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0.6) 11%,
rgba(255, 255, 255, 0) 32%
),
radial-gradient(
circle at var(--seal-mx, 50%) var(--seal-my, 50%),
hsl(var(--seal-sheen-hue, 200) 100% 72% / 0.9) 0%,
hsl(var(--seal-sheen-hue, 200) 100% 60% / 0) 52%
);
}
.aura cf-avatar {
position: relative;
z-index: 1;
}
.badge[data-state="verified"] .aura {
padding: 3px;
isolation: isolate;
transition: transform 200ms ease-out;
}
.badge[data-state="verified"] .aura cf-avatar {
box-shadow: 0 0 0 2px var(--cf-theme-color-surface, hsl(0, 0%, 99%));
border-radius: var(--cf-border-radius-full, 9999px);
}
/* Ambient self-motion is gated to :hover so a dense roster stays calm at
rest and a seal comes alive when engaged. The cursor sheen (.aura-sheen,
above) is the *always-on* part — it reacts to the host cursor anywhere on
screen, hovered or not, which is the unforgeable signal. On hover the ring
rotates, the shimmer sweeps, and the seal lifts a touch. */
.badge[data-state="verified"] .aura:hover .aura-ring {
animation: cf-aura-spin 26s linear infinite;
}
.badge[data-state="verified"] .aura:hover .aura-glow {
opacity: 1;
animation: cf-aura-glow 7s linear infinite;
}
.badge[data-state="verified"] .aura:hover {
transform: scale(1.04);
transition: transform 160ms ease-out;
}
@keyframes cf-aura-spin {
to {
transform: rotate(1turn);
}
}
@keyframes cf-aura-glow {
0% {
background-position: 0% 0%;
}
100% {
background-position: 280% 280%;
}
}
@media (prefers-reduced-motion: reduce) {
.badge[data-state="verified"] .aura:hover .aura-ring,
.badge[data-state="verified"] .aura:hover .aura-glow {
animation: none;
}
.badge[data-state="verified"] .aura:hover .aura-glow {
opacity: 0;
}
.badge[data-state="verified"] .aura:hover {
transform: none;
}
}
/* ---- Variants (CT-1761) -------------------------------------------- */
/* chip: compact name-first pill. No avatar — a small DID-hued "seal dot"
carries the identity treatment, so an inline name still reads as a
first-class, verifiable identity rather than plain text. */
.badge[data-variant="chip"] {
gap: 0.375rem;
padding: 0.125rem 0.5rem 0.125rem 0.3125rem;
}
.seal-dot {
display: block;
width: 0.5rem;
height: 0.5rem;
border-radius: var(--cf-border-radius-full, 9999px);
background: var(--cf-theme-color-border, hsl(0, 0%, 80%));
box-sizing: border-box;
}
/* The verified dot is filled with the identity accent (supplied inline)
and sits inside the shared .aura, so it inherits the same ring + glow +
cursor-sheen liveness as the full badge — just at dot scale. */
.badge[data-variant="chip"][data-state="verified"] .aura .seal-dot {
box-shadow: 0 0 0 1.5px var(--cf-theme-color-surface, hsl(0, 0%, 99%));
}
/* circle: avatar + seal ring only. Drop the pill chrome and the name; the
ring IS the seal. Used for dense avatar strips and message gutters. */
.badge[data-variant="circle"] {
gap: 0;
padding: 0;
border: none;
background: transparent;
}
/* hero: large, centered avatar-over-name. For a profile page header — the
seal aura is the focal point, so drop the pill chrome and stack the name
beneath the avatar at display scale. */
.badge[data-variant="hero"] {
flex-direction: column;
gap: 0.625rem;
padding: 0;
border: none;
background: transparent;
align-items: center;
text-align: center;
}
.badge[data-variant="hero"] .name {
font-size: var(--cf-font-size-xl, 1.5rem);
font-weight: var(--cf-font-weight-bold, 700);
max-width: min(28ch, 100%);
white-space: normal;
}
`,
];
/** The profile cell to present. Bound from a pattern via `$profile={cell}`. */
@property({ attribute: false })
accessor profile: CellHandle | undefined = undefined;
@property({ type: String, reflect: true })
accessor size: AvatarSize = "md";
/**
* Badge shape. The verification treatment is ALWAYS the generative seal — the
* DID-derived aura ring + the cursor-reactive glint (a pattern can mimic the
* chrome but cannot earn the seal for a DID it doesn't control). Variants
* differ only in how much chrome they draw around it:
* - `full` (default): avatar + name pill. Roster rows, "playing as", etc.
* - `chip`: name + a compact DID-hued seal dot (no avatar). For inline names
* in dense UI where a full pill is too heavy but the identity treatment
* should still read.
* - `circle`: avatar + seal ring only, no name text (the name rides
* `aria-label` + the hover tooltip). For avatar strips and message gutters.
* - `hero`: large, centered avatar-over-name presentation. For a profile
* page header, where the seal IS the point. Pair with `noNavigate`.
* @attr {string} variant - full | chip | circle | hero (default full)
*/
@property({ type: String, reflect: true })
accessor variant: "full" | "chip" | "circle" | "hero" = "full";
@consume({ context: runtimeContext, subscribe: true })
@property({ attribute: false })
accessor runtime: RuntimeClient | undefined = undefined;
@consume({ context: spaceContext, subscribe: true })
@property({ attribute: false })
accessor space: DID | undefined = undefined;
/**
* Suppress click-to-navigate. Set this when the badge is bound to a derived
* view of the profile rather than the profile's own root piece — e.g. the
* self-badge on a profile-home page, whose bound cell is a `computed()`
* projection (not a navigable piece), so a click would otherwise resolve to a
* non-piece cell id and route to an invalid URL.
*/
@property({ type: Boolean, reflect: true, attribute: "nonavigate" })
accessor noNavigate = false;
@state()
private accessor _name: string | undefined = undefined;
@state()
private accessor _avatar: string | undefined = undefined;
// CT-1648: extra details surfaced in the hover/focus tooltip.
@state()
private accessor _bio: string | undefined = undefined;
@state()
private accessor _pinnedCount = 0;
@state()
private accessor _state: ProfileBadgeState = "presented";
/** Generative identity seal, derived from the owner DID once verified. */
@state()
private accessor _seal: IdentitySeal | undefined = undefined;
// CT-1750: navigation. `_resolvedCell` is the resolved profile cell;
// `_navigable` is true only when it's a root cell (a real profile piece). A
// badge bound to a real profile (rosters/lists) navigates to that profile's
// page on click; one bound to a derived/sub-path cell (e.g. a self-view
// `{name, avatar}` cell on the profile page itself) is non-navigable and the
// click is a no-op.
private _resolvedCell: CellHandle | undefined = undefined;
@state()
private accessor _navigable = false;
private _unsubscribe?: () => void;
private _resolveGeneration = 0;
// Liveness: whether this seal is currently registered with the shared cursor
// controller, and the last sheen alpha written (so far-from-cursor frames can
// skip redundant style writes).
private _livenessRegistered = false;
private _lastSheenA = -1;
override connectedCallback(): void {
super.connectedCallback();
void this._resolve();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
// Bump the generation so any in-flight `_resolve` continuation fails its
// `generation !== this._resolveGeneration` guard and bails before
// subscribing / writing state on this now-detached instance. Without this,
// an element that disconnects DURING the `await cell.resolveAsCell()` would
// resume, pass the (unchanged) generation check, and leak a live
// subscription that updates a detached element.
this._resolveGeneration++;
this._cleanup();
this._setLiveness(false);
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
if (changed.has("profile")) {
void this._resolve();
}
}
protected override updated(changed: PropertyValues): void {
super.updated(changed);
// Reflect navigability to the host so `:host([data-navigable])` can draw the
// pointer cursor over the whole host box (not just the inner `.badge`).
this.toggleAttribute("data-navigable", this._navigable);
// Register for cursor sheen only while actually verified + connected. The
// shared controller manages reduced-motion (it won't run the loop while the
// user prefers reduced motion, and tears it down live if they enable it).
const verified = this._state === "verified" && this._seal !== undefined;
this._setLiveness(verified && this.isConnected);
}
private _setLiveness(on: boolean): void {
if (on === this._livenessRegistered) return;
this._livenessRegistered = on;
if (on) {
registerSeal(this);
} else {
unregisterSeal(this);
this._lastSheenA = -1;
}
}
/**
* Called once per animation frame by the shared liveness controller while
* this seal is registered. Places a reflective hotspot on the seal in the
* direction of the host cursor, brightening as the cursor nears — and
* responding even when the cursor is far away (the unforgeable part). Reads
* the aura's own rect so geometry is correct regardless of the badge's
* surrounding layout, and culls when offscreen.
*/
updateSeal(cursorX: number, cursorY: number, frameMs: number): void {
const aura = this.shadowRoot?.querySelector(".aura") as HTMLElement | null;
if (!aura) return;
const r = aura.getBoundingClientRect();
const vh = globalThis.innerHeight ?? 0;
if (r.width === 0 || r.bottom < -80 || r.top > vh + 80) {
if (this._lastSheenA !== 0) {
this.style.setProperty("--seal-sheen-a", "0");
this._lastSheenA = 0;
}
return;
}
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = cursorX - cx;
const dy = cursorY - cy;
const dist = Math.hypot(dx, dy);
const prox = Math.max(0, 1 - dist / 520); // 1 at center → 0 by ~520px
const a = Math.min(1, prox * prox * 1.25 + prox * 0.15); // punchy near
// Far + already dark: nothing to draw, skip all writes (cheap at scale).
if (a === 0 && this._lastSheenA === 0) return;
this._lastSheenA = a;
const reach = Math.max(r.width, r.height) * 1.9;
const nx = Math.max(-1, Math.min(1, dx / reach));
const ny = Math.max(-1, Math.min(1, dy / reach));
const hue = this._seal?.hue ?? 0;
this.style.setProperty("--seal-mx", `${(50 + nx * 62).toFixed(1)}%`);
this.style.setProperty("--seal-my", `${(50 + ny * 62).toFixed(1)}%`);
this.style.setProperty("--seal-sheen-a", a.toFixed(3));
this.style.setProperty(
"--seal-sheen-hue",
String(Math.round((hue + frameMs * 0.03 + nx * 40 + 360) % 360)),
);
}
/**
* Reset the cursor sheen to nothing. Called by the shared controller when it
* stops the loop (e.g. the user enables reduced motion) so the highlight
* doesn't freeze mid-glint.
*/
clearSeal(): void {
this.style.setProperty("--seal-sheen-a", "0");
this._lastSheenA = 0;
}
private _cleanup(): void {
this._unsubscribe?.();
this._unsubscribe = undefined;
}
private async _resolve(): Promise {
const generation = ++this._resolveGeneration;
this._cleanup();
// Clear any prior verification up-front: a re-bind to a different profile
// must not keep showing the previous profile's seal during the async
// re-resolve + attestation gap. `_refreshVerification` re-derives it below.
this._state = "presented";
this._seal = undefined;
// Drop navigation state until the (new) cell resolves — a stale link must
// not survive a re-bind.
this._resolvedCell = undefined;
this._navigable = false;
const cell = this.profile;
if (!cell) {
this._applyValue(undefined);
return;
}
try {
const resolved = await cell.resolveAsCell();
// Bail if a newer resolve superseded us OR if we were disconnected during
// the await (disconnectedCallback bumps the generation, so the first guard
// already covers detachment; isConnected is kept as a belt-and-braces
// check so we never subscribe / write state on a detached instance).
if (generation !== this._resolveGeneration || !this.isConnected) return;
// CT-1750: remember the resolved cell and whether it's a navigable root
// piece (only root cells map to a profile page; sub-path/derived cells
// don't).
this._resolvedCell = resolved;
this._navigable = !this.noNavigate && resolved.ref().path.length === 0;
// Subscribe with a minimal schema so the runtime only resolves the fields
// we render, rather than walking the whole profile output graph (mirrors
// cf-cell-link's $NAME-only subscription). `bio` + `elements` feed the
// hover tooltip (CT-1648); `elements` items request only `title` (a
// same-space string) so we never deep-resolve the cross-space `cell` links
// just to count them.
const named = resolved.asSchema<
{
[NAME]?: string;
name?: string;
avatar?: string;
bio?: string;
elements?: Array<{ title?: string }>;
}
>({
type: "object",
properties: {
[NAME]: { type: "string" },
name: { type: "string" },
avatar: { type: "string" },
bio: { type: "string" },
elements: {
type: "array",
items: {
type: "object",
properties: { title: { type: "string" } },
},
},
},
});
// The runtime-attested label rides each subscription update
// (`includeCfcLabel`), read on the sink's tracked tx — so verification
// re-derives whenever the profile's label changes (re-labeling, or the
// doc first loading), not just on name/avatar edits, with no separate
// getCfcLabel round-trip. The `generation` guard drops a callback that
// fires after a re-bind/detach.
this._unsubscribe = named.subscribe((val, cfcLabel) => {
this._applyValue(val);
this._deriveVerification(cfcLabel, generation);
}, { includeCfcLabel: true });
} catch (e) {
if (generation !== this._resolveGeneration || !this.isConnected) return;
// A disposal race (logout, runtime swap) cancels the resolve; that is
// cancellation, not a failure to surface. Read the cell's own runtime,
// not the ambient `this.runtime` (cleared to undefined on logout).
if (cell.runtime().signal.aborted) return;
console.error("cf-profile-badge: failed to resolve profile cell", e);
this._resolvedCell = undefined;
this._navigable = false;
this._applyValue(undefined);
}
}
/**
* Navigate to the bound profile's page (CT-1750). No-op unless the resolved
* cell is a navigable root piece. Mirrors cf-cell-link's navigation; Cmd/Ctrl
* opens in a new tab.
*/
private _navigateToProfile(openInNewTab: boolean): void {
if (this.noNavigate) return;
const cell = this._resolvedCell;
if (!cell || cell.ref().path.length > 0) return;
const view = { spaceDid: cell.space(), pieceId: cell.id() };
if (openInNewTab) {
const url = appViewToUrlPath(
preserveAppViewMode(
urlToAppView(new URL(globalThis.location.href)),
view,
),
);
globalThis.open(url, "_blank", "noopener");
} else {
navigate(view);
}
}
private _handleClick(e: MouseEvent): void {
if (!this._navigable) return;
e.stopPropagation();
this._navigateToProfile(e.metaKey || e.ctrlKey);
}
private _handleKeydown(e: KeyboardEvent): void {
if (!this._navigable) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._navigateToProfile(e.metaKey || e.ctrlKey);
}
}
private _applyValue(val: unknown): void {
const { name, avatar } = profileDisplayFromValue(val);
const { bio, pinnedCount } = profileTooltipFromValue(val);
this._name = name;
this._avatar = avatar;
this._bio = bio;
this._pinnedCount = pinnedCount;
this.requestUpdate();
}
/**
* Reads the resolved cell's runtime-attested CFC label and, if it carries a
* `represents-principal` atom, enters the verified state and derives the
* generative seal from the owner principal DID. The seal is a pure function of
* the DID, so it is identical wherever this person's badge renders. No
* attestation → the badge stays in the plain "presented" state (a pattern can
* mimic the chrome but cannot mint the label that unlocks the seal).
*
* `generation` is captured by the subscription callback in `_resolve`; a
* callback that fires after a re-bind/detach (which bumps the generation)
* bails before writing `_state`/`_seal`, so it can't leak onto a stale or
* detached instance.
*/
private _deriveVerification(
cfcLabel: CfcLabelView | undefined,
generation: number,
): void {
if (generation !== this._resolveGeneration || !this.isConnected) return;
// The label is the runtime-attested, display-redacted view delivered over
// the subscription; redaction strips Caveat.source but keeps the
// `represents-principal` integrity atom that unlocks the seal.
const owner = ownerPrincipalFromLabel(cfcLabel);
if (owner) {
this._seal = identitySeal(owner);
this._state = "verified";
} else {
this._seal = undefined;
this._state = "presented";
}
}
override render() {
const verified = this._state === "verified" && this._seal !== undefined;
// The aura ring layer carries the DID-derived conic gradient plus a soft glow
// in the identity's hue, so the fingerprint reads at badge scale.
const hue = this._seal?.hue ?? 0;
const ringStyle = verified
? `background: ${
this._seal!.ringGradient
}; box-shadow: 0 0 0 1px hsl(${hue} 80% 58% / 0.3), 0 0 10px -1px hsl(${hue} 85% 60% / 0.7);`
: "";
// The chip's seal-dot is filled with the same DID-derived accent when
// verified (supplied inline), so an inline name still carries the identity's
// color even without an avatar.
const dotStyle = verified ? `background: ${this._seal!.accent};` : "";
// Per-identity hue, supplied inline so the hover shimmer is tinted to match
// this identity's palette.
const auraStyle = verified ? `--seal-hue: ${hue};` : "";
// CT-1761: variant shape. The verification signal is ALWAYS the generative
// seal (aura ring + cursor glint) — there is no separate shield icon. `full`
// is an avatar+name pill; `chip` drops the avatar for a compact name + seal
// dot; `circle` drops the name for an avatar + seal ring (name rides
// aria-label/tooltip); `hero` is a large avatar-over-name presentation.
const variant = this.variant;
const showAvatar = variant !== "chip";
const showName = variant !== "circle";
const displayName = this._name ?? "Unknown profile";
// CT-1648: hover/focus tooltip surfacing the profile's configured details
// (bio + pinned-piece count). Always shown for `circle` (whose name is
// otherwise invisible); otherwise only when there's something beyond the
// already-visible name.
const pinnedLabel = this._pinnedCount === 1
? "1 pinned piece"
: `${this._pinnedCount} pinned pieces`;
const hasTooltip = this._bio !== undefined || this._pinnedCount > 0 ||
variant === "circle";
return html`
${verified
? html`
`
: null} ${showAvatar
? html`
`
: html`
`} ${verified
? html`
`
: null}
${showName
? html`
${displayName}
`
: null} ${hasTooltip
? html`
${this._name ?? "Profile"}
${this._bio !== undefined
? html`
${this._bio}
`
: null} ${this._pinnedCount > 0
? html`
📌 ${pinnedLabel}
`
: null}
`
: null}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cf-profile-badge": CFProfileBadge;
}
}