Skip to content

Commit 5d8574f

Browse files
committed
Harden Facehash SVG against ancestor sizing rules
1 parent 874888f commit 5d8574f

9 files changed

Lines changed: 126 additions & 32 deletions

File tree

apps/web/src/components/ui/avatar-input.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe("AvatarInput", () => {
1313
expect(html).toContain("Preview");
1414
});
1515

16-
it("renders image preview (and fallback container) when value exists", () => {
16+
it("renders the fallback container while delegating image loading to the client", () => {
1717
const html = renderToStaticMarkup(
1818
<AvatarInput
1919
onChange={() => {}}
@@ -26,7 +26,7 @@ describe("AvatarInput", () => {
2626
/>
2727
);
2828

29-
expect(html).toContain("https://cdn.example.com/logo.png");
3029
expect(html).toContain('data-slot="avatar-fallback"');
30+
expect(html).not.toContain('data-slot="avatar-image"');
3131
});
3232
});

apps/web/src/components/ui/avatar.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it, mock } from "bun:test";
22
import type React from "react";
33
import { renderToStaticMarkup } from "react-dom/server";
4+
import { Button } from "./button";
45

56
mock.module("./tooltip", () => ({
67
TooltipOnHover: ({
@@ -19,16 +20,42 @@ mock.module("./tooltip", () => ({
1920
const modulePromise = import("./avatar");
2021

2122
describe("Avatar facehash wrapper", () => {
22-
it("pins the fallback foreground to black", async () => {
23+
it("keeps standalone facehash interactive and pins the foreground to black", async () => {
2324
const { Facehash } = await modulePromise;
2425
const html = renderToStaticMarkup(
2526
<Facehash className="text-white dark:text-white" name="agent-47" />
2627
);
2728

2829
expect(html).toContain("color:#000000");
30+
expect(html).toContain("transform-box:view-box");
2931
expect(html).toContain("display:block;overflow:visible;color:inherit");
3032
});
3133

34+
it("renders avatar fallbacks as non-interactive facehashes", async () => {
35+
const { Avatar } = await modulePromise;
36+
const html = renderToStaticMarkup(
37+
<Avatar fallbackName="Quick Tornado" tooltipContent={null} url={null} />
38+
);
39+
40+
expect(html).toContain('data-slot="avatar-fallback"');
41+
expect(html).not.toContain("transform-box:view-box");
42+
expect(html).toContain("color:#000000");
43+
});
44+
45+
it("keeps the facehash scene full-size inside shared buttons", async () => {
46+
const { Avatar } = await modulePromise;
47+
const html = renderToStaticMarkup(
48+
<Button size="icon-small" variant="ghost">
49+
<Avatar fallbackName="Quick Tornado" tooltipContent={null} url={null} />
50+
</Button>
51+
);
52+
53+
expect(html).toContain('data-slot="button"');
54+
expect(html).toContain(
55+
"display:block;overflow:visible;color:inherit;width:100%;height:100%"
56+
);
57+
});
58+
3259
it("uses a fit-content wrapper and supports overriding tooltip content", async () => {
3360
const { Avatar } = await modulePromise;
3461
const html = renderToStaticMarkup(

apps/web/src/components/ui/avatar.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import {
44
PRESENCE_AWAY_WINDOW_MS,
55
PRESENCE_ONLINE_WINDOW_MS,
66
} from "@cossistant/types";
7-
import * as AvatarPrimitive from "@radix-ui/react-avatar";
8-
import { Facehash as FacehashComponent } from "facehash";
7+
import {
8+
AvatarFallback as FacehashAvatarFallbackPrimitive,
9+
AvatarImage as FacehashAvatarImagePrimitive,
10+
Avatar as FacehashAvatarPrimitive,
11+
Facehash as FacehashComponent,
12+
} from "facehash";
913
import type * as React from "react";
1014
import { useEffect, useState } from "react";
1115
import { formatTimeAgo } from "@/lib/date";
@@ -16,9 +20,9 @@ import { TooltipOnHover } from "./tooltip";
1620
function AvatarContainer({
1721
className,
1822
...props
19-
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
23+
}: React.ComponentProps<typeof FacehashAvatarPrimitive>) {
2024
return (
21-
<AvatarPrimitive.Root
25+
<FacehashAvatarPrimitive
2226
className={cn(
2327
"relative flex size-8 shrink-0 overflow-hidden rounded",
2428
className
@@ -32,9 +36,9 @@ function AvatarContainer({
3236
function AvatarImage({
3337
className,
3438
...props
35-
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
39+
}: React.ComponentProps<typeof FacehashAvatarImagePrimitive>) {
3640
return (
37-
<AvatarPrimitive.Image
41+
<FacehashAvatarImagePrimitive
3842
className={cn("aspect-square size-full", className)}
3943
data-slot="avatar-image"
4044
{...props}
@@ -43,7 +47,10 @@ function AvatarImage({
4347
}
4448

4549
interface AvatarFallbackProps
46-
extends React.ComponentProps<typeof AvatarPrimitive.Fallback> {
50+
extends Omit<
51+
React.ComponentProps<typeof FacehashAvatarFallbackPrimitive>,
52+
"children" | "facehashProps" | "name"
53+
> {
4754
value?: string | null;
4855
children?: string;
4956
}
@@ -87,6 +94,7 @@ function Facehash({
8794

8895
function AvatarFallback({
8996
className,
97+
style,
9098
value,
9199
children,
92100
...props
@@ -95,16 +103,25 @@ function AvatarFallback({
95103
getNonEmptyString(value) ?? getNonEmptyString(children) ?? "avatar";
96104

97105
return (
98-
<AvatarPrimitive.Fallback
106+
<FacehashAvatarFallbackPrimitive
99107
className={cn(
100108
"flex size-full items-center justify-center text-black dark:text-black",
101109
className
102110
)}
103111
data-slot="avatar-fallback"
112+
facehashProps={{
113+
colorClasses: COSSISTANT_FACEHASH_COLOR_CLASSES,
114+
enableBlink: true,
115+
intensity3d: "dramatic",
116+
interactive: false,
117+
}}
118+
name={facehashName}
119+
style={{
120+
color: "#000000",
121+
...style,
122+
}}
104123
{...props}
105-
>
106-
<Facehash name={facehashName} />
107-
</AvatarPrimitive.Fallback>
124+
/>
108125
);
109126
}
110127

packages/facehash/src/__snapshots__/facehash-scene-svg.test.tsx.snap

Lines changed: 12 additions & 12 deletions
Large diffs are not rendered by default.

packages/facehash/src/core/scene.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,25 @@ describe("createFacehashScene", () => {
6262
expect(Math.abs(scene.projection.skewX)).toBe(0);
6363
expect(Math.abs(scene.projection.skewY)).toBe(0);
6464
});
65+
66+
it("normalizes short eye geometries without changing taller variants", () => {
67+
const roundScene = createFacehashScene({
68+
name: findNameForFaceType("round"),
69+
});
70+
const crossScene = createFacehashScene({
71+
name: findNameForFaceType("cross"),
72+
});
73+
const lineScene = createFacehashScene({
74+
name: "Support team",
75+
});
76+
77+
expect(roundScene.data.faceType).toBe("round");
78+
expect(crossScene.data.faceType).toBe("cross");
79+
expect(lineScene.data.faceType).toBe("line");
80+
81+
expect(roundScene.faceBox.height).toBeCloseTo(60 * (15 / 63), 3);
82+
expect(crossScene.faceBox.height).toBeCloseTo(60 * (23 / 71), 3);
83+
expect(lineScene.faceBox.height).toBeCloseTo(60 * (15 / 82), 3);
84+
expect(lineScene.faceBox.height).toBeGreaterThan(10);
85+
});
6586
});

packages/facehash/src/core/scene.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const FACE_WIDTH = 60;
8989
const FACE_CENTER_Y = 37;
9090
const INITIAL_Y = 70;
9191
const INITIAL_FONT_SIZE = 26;
92+
const MIN_VISUAL_FACE_HEIGHT = 15;
9293

9394
function toFixedNumber(value: number): number {
9495
return Number(value.toFixed(3));
@@ -158,8 +159,13 @@ export function createFacehashScene(
158159
const data = computeFacehash({ name, colorsLength });
159160
const rotation = pose === "front" ? { x: 0, y: 0 } : data.rotation;
160161
const faceGeometry = FACE_GEOMETRIES[data.faceType];
162+
// Keep short eye geometries visually comparable to the other face variants.
163+
const visualFaceHeight = Math.max(
164+
faceGeometry.viewBox.height,
165+
MIN_VISUAL_FACE_HEIGHT
166+
);
161167
const aspectRatio =
162-
faceGeometry.viewBox.width / Math.max(faceGeometry.viewBox.height, 1);
168+
faceGeometry.viewBox.width / Math.max(visualFaceHeight, 1);
163169
const faceHeight = FACE_WIDTH / aspectRatio;
164170
const projection = createProjection(rotation, intensity3d);
165171
const preset = PROJECTION_PRESETS[intensity3d];

packages/facehash/src/facehash-scene-svg.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export function FacehashSceneSvg({
5757
display: "block",
5858
overflow: "visible",
5959
...style,
60+
width,
61+
height,
6062
}}
6163
viewBox="0 0 100 100"
6264
width={width}

packages/facehash/src/facehash.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ describe("Facehash", () => {
99
);
1010

1111
expect(html).toContain('class="facehash text-[#123456]"');
12-
expect(html).toContain("display:block;overflow:visible;color:inherit");
12+
expect(html).toContain(
13+
"display:block;overflow:visible;color:inherit;width:100%;height:100%"
14+
);
1315
expect(html).not.toContain("color:black");
1416
});
1517
});

packages/react/src/support/components/conversation-button-link.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
22
import { type Conversation, ConversationStatus } from "@cossistant/types";
3+
import { Facehash } from "facehash";
34
import React from "react";
45
import { renderToStaticMarkup } from "react-dom/server";
56
import type { SupportTextResolvedFormatter } from "../text/locales/keys";
@@ -45,10 +46,19 @@ mock.module("../text", () => ({
4546

4647
mock.module("./avatar", () => ({
4748
Avatar: ({ name, facehashName }: { name: string; facehashName?: string }) =>
48-
React.createElement("div", {
49-
"data-avatar": name,
50-
"data-facehash-name": facehashName ?? "",
51-
}),
49+
React.createElement(
50+
"div",
51+
{
52+
"data-avatar": name,
53+
"data-facehash-name": facehashName ?? "",
54+
},
55+
React.createElement(Facehash, {
56+
className: "size-full text-black",
57+
interactive: false,
58+
name: facehashName ?? name,
59+
size: "100%",
60+
})
61+
),
5262
}));
5363

5464
mock.module("./icons", () => ({
@@ -277,4 +287,13 @@ describe("ConversationButtonLink", () => {
277287
expect(html).toContain('data-avatar="Support Team"');
278288
expect(html).toContain('data-facehash-name="support@example.com"');
279289
});
290+
291+
it("keeps the nested facehash scene full-size inside the conversation button", async () => {
292+
const html = await renderConversationButtonLink();
293+
294+
expect(html).toContain("group/btn");
295+
expect(html).toContain(
296+
"display:block;overflow:visible;color:inherit;width:100%;height:100%"
297+
);
298+
});
280299
});

0 commit comments

Comments
 (0)