From dfc2e7998dcf2b3ced2d755d864e13583d5f13f4 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:31:54 -0700
Subject: [PATCH] feat: add Paper default human avatars
Unpicked people and human photos that fail to load render deterministic Paper. Loaded photos and saved Cut picks remain ahead of the fallback; agent faces are unchanged. Account surfaces use the human identity.
Written by Sprint Impl, a Commonly agent
Pod: https://commonly.me/v2/pods/6a692a1be833c668acdb84cf
---
.../src/v2/__tests__/V2AccountMenu.test.tsx | 44 +++++++++++++++++++
frontend/src/v2/__tests__/V2Avatar.test.tsx | 43 +++++++++++++++++-
.../src/v2/__tests__/V2SettingsPage.test.tsx | 5 ++-
.../src/v2/__tests__/avatarCharacter.test.ts | 30 +++++++++++--
.../v2/__tests__/v2-layout-invariants.test.ts | 15 +++++++
frontend/src/v2/components/V2AccountMenu.tsx | 8 +++-
frontend/src/v2/components/V2Avatar.tsx | 29 +++++++-----
frontend/src/v2/components/V2SettingsPage.tsx | 1 +
frontend/src/v2/utils/avatarKit.ts | 31 +++++++++++++
frontend/src/v2/utils/avatars.ts | 22 +++++++++-
frontend/src/v2/v2.css | 7 +++
11 files changed, 214 insertions(+), 21 deletions(-)
create mode 100644 frontend/src/v2/__tests__/V2AccountMenu.test.tsx
diff --git a/frontend/src/v2/__tests__/V2AccountMenu.test.tsx b/frontend/src/v2/__tests__/V2AccountMenu.test.tsx
new file mode 100644
index 000000000..c24517adb
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2AccountMenu.test.tsx
@@ -0,0 +1,44 @@
+// @ts-nocheck
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { AuthContext } from '../../context/AuthContext';
+import V2AccountMenu from '../components/V2AccountMenu';
+
+describe('V2AccountMenu avatar', () => {
+ test('uses the signed-in human identity and has no online dot', () => {
+ const user = {
+ _id: 'user-paper-233',
+ username: 'Ada',
+ profilePicture: 'default',
+ role: 'member',
+ };
+ const auth = {
+ currentUser: user,
+ user,
+ token: null,
+ loading: false,
+ error: null,
+ isAuthenticated: true,
+ register: jest.fn(),
+ login: jest.fn(),
+ logout: jest.fn(),
+ updateProfile: jest.fn(),
+ };
+
+ const { container } = render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Open account menu' })).toBeInTheDocument();
+ const avatar = container.querySelector('.v2-rail__account .v2-avatar');
+ expect(avatar).toHaveClass('v2-avatar--md');
+ expect(avatar.querySelector('.v2-avatar__online')).toBeNull();
+ expect(avatar.querySelector('img')).toHaveAttribute('alt', 'Ada');
+ expect(avatar.querySelector('img').getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2Avatar.test.tsx b/frontend/src/v2/__tests__/V2Avatar.test.tsx
index e500bc226..cbcfb3688 100644
--- a/frontend/src/v2/__tests__/V2Avatar.test.tsx
+++ b/frontend/src/v2/__tests__/V2Avatar.test.tsx
@@ -1,7 +1,11 @@
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import V2Avatar from '../components/V2Avatar';
+const svgFromDataUri = (src: string | null): string => decodeURIComponent(
+ String(src).replace(/^data:image\/svg\+xml;utf8,/, ''),
+);
+
describe('V2Avatar', () => {
const originalApiUrl = process.env.REACT_APP_API_URL;
@@ -43,4 +47,41 @@ describe('V2Avatar', () => {
rerender();
expect(screen.getByRole('img', { name: 'Ada renamed' })).toHaveAttribute('src', source);
});
+
+ test('uses Paper for an unpicked human, preserves a stored Cut pick, and lets photos win', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ let image = screen.getByRole('img', { name: 'Ada' });
+ let svg = svgFromDataUri(image.getAttribute('src'));
+ expect(svg).toContain('M8 64C8 51 18 46 32 46C46 46 56 51 56 64Z');
+ expect(svg).not.toMatch(/fill="#(?:ffe4c0|f5d7b1|efcc9f|e2ba87|c99c62|a47539|8c5a2b|643d19)"/);
+
+ rerender(
+ ,
+ );
+ image = screen.getByRole('img', { name: 'Ada' });
+ svg = svgFromDataUri(image.getAttribute('src'));
+ expect(svg).toContain('fill="#e2ba87"');
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('img', { name: 'Ada' })).toHaveAttribute(
+ 'src', 'https://api.commonly.me/api/uploads/ada.png',
+ );
+ });
+
+ test('falls back to Paper when a human photo fails to load', () => {
+ render(
+ ,
+ );
+
+ fireEvent.error(screen.getByRole('img', { name: 'Ada' }));
+
+ const svg = svgFromDataUri(screen.getByRole('img', { name: 'Ada' }).getAttribute('src'));
+ expect(svg).toContain('');
+ expect(svg).not.toMatch(/fill="#(?:ffe4c0|f5d7b1|efcc9f|e2ba87|c99c62|a47539|8c5a2b|643d19)"/);
+ });
});
diff --git a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx
index 0b0cbe2db..fcd9da21a 100644
--- a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx
@@ -22,8 +22,8 @@ jest.mock('../../components/AppsManagement', () => {
});
jest.mock('../components/V2Avatar', () => {
- const MockV2Avatar = ({ name, src, className }: { name?: string; src?: string; className?: string }) => (
-
+ const MockV2Avatar = ({ name, src, className, kind }: { name?: string; src?: string; className?: string; kind?: string }) => (
+
);
MockV2Avatar.displayName = 'MockV2Avatar';
return MockV2Avatar;
@@ -84,6 +84,7 @@ describe('V2SettingsPage', () => {
expect(screen.getByText('Connected app controls')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveClass('v2-settings__avatar');
expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('src', '/uploads/lily.png');
+ expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('data-kind', 'human');
expect(screen.getByText('Connected app controls')).toHaveAttribute('data-variant', 'settings');
expect(screen.getByRole('radio', { name: 'English' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: '中文' })).toBeInTheDocument();
diff --git a/frontend/src/v2/__tests__/avatarCharacter.test.ts b/frontend/src/v2/__tests__/avatarCharacter.test.ts
index 845bb5678..f72c4b23f 100644
--- a/frontend/src/v2/__tests__/avatarCharacter.test.ts
+++ b/frontend/src/v2/__tests__/avatarCharacter.test.ts
@@ -1,4 +1,6 @@
-import { characterAvatarFor, PICKER_ARCHETYPES, PICKER_CELL_COUNT } from '../utils/avatars';
+import {
+ characterAvatarFor, paperAvatarFor, PICKER_ARCHETYPES, PICKER_CELL_COUNT,
+} from '../utils/avatars';
import {
faceTraitsFor, renderFace, HAIR_STYLES, SKIN_TONES, FaceTraits,
} from '../utils/avatarKit';
@@ -48,9 +50,8 @@ describe('characterAvatarFor', () => {
// hashString(key) % 4, so a single seed exercises one of four slots and a
// wrong-but-lucky family would pass.
const seeds = Array.from({ length: 200 }, (_, i) => `species-${i}`);
- const groundsFor = (kind: 'human' | 'agent') => seeds.map((s) => groundOf(characterAvatarFor(s, kind)));
- const human = groundsFor('human');
- const agent = groundsFor('agent');
+ const human = seeds.map((s) => groundOf(paperAvatarFor(s)));
+ const agent = seeds.map((s) => groundOf(characterAvatarFor(s, 'agent')));
// A family is a family: the ground varies within each kind...
expect(new Set(human).size).toBeGreaterThanOrEqual(3);
@@ -101,6 +102,27 @@ describe('characterAvatarFor', () => {
}
});
+ test('unpicked humans get all 24 seeded Paper looks without any skin-tone fill', () => {
+ const seeds = Array.from({ length: 240 }, (_, i) => `paper-user-${i}`);
+ const uris = seeds.map((seed) => paperAvatarFor(seed));
+ expect(uris.every(Boolean)).toBe(true);
+ expect(new Set(uris).size).toBe(24);
+
+ for (const uri of uris) {
+ const svg = svgOf(uri);
+ expect(SKIN_TONES.some((tone) => svg.includes(`fill="#${tone}"`))).toBe(false);
+ expect(svg).toContain('M8 64C8 51 18 46 32 46C46 46 56 51 56 64Z');
+ expect(svg).toContain('cx="32" cy="29" rx="13" ry="15"');
+ expect(svg).toContain('');
+ expect(svg).not.toMatch(/ {
+ const svg = svgOf(characterAvatarFor('paper-user-v13', 'human'));
+ expect(svg).toContain('fill="#e2ba87"');
+ });
+
test('the archetype table stays representation-complete', () => {
// The four ethnic rows and both gender presentations must survive edits:
// all 8 skin tones appear somewhere, and both accessory shapes
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index b6b539f85..2d3a50644 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -226,6 +226,20 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(active).toContain('background: var(--v2-ink)');
});
+ test('the rail account avatar matches the nav-mark axis as a 32px hard-edged square', () => {
+ const accountButton = lastRuleBody(v2, '.v2-rail__account');
+ const accountAvatar = lastRuleBody(v2, '.v2-rail__account .v2-avatar');
+ const accountCluster = ruleBody(v2, '.v2-rail__user');
+ expect(accountButton).toContain('width: 32px');
+ expect(accountButton).toContain('height: 32px');
+ expect(accountAvatar).toContain('width: 32px');
+ expect(accountAvatar).toContain('height: 32px');
+ expect(accountAvatar).toContain('border-radius: var(--v2-radius-sm)');
+ expect(accountAvatar).toContain('border: 0');
+ expect(accountAvatar).toContain('box-shadow: none');
+ expect(accountCluster).toContain('align-items: center');
+ });
+
test('sidebar is direction C: a white panel with search, Pinned / Recent / Everything, and rows that carry mark, pill and time', () => {
// Walk-1 rulings (2026-09-06): the six 11px mono labels are gone; two
// 12px semibold section heads plus a folded inventory replace them.
@@ -873,6 +887,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(settingsPage).toContain('');
expect(settingsPage).toContain('className="v2-settings__avatar"');
expect(settingsPage).toContain('src={currentUser?.profilePicture || undefined}');
+ expect(settingsPage).toContain('kind="human"');
expect(settingsPage).toContain('');
expect(settingsPage).toContain('');
expect(settingsPage).toContain('');
diff --git a/frontend/src/v2/components/V2AccountMenu.tsx b/frontend/src/v2/components/V2AccountMenu.tsx
index 80fdf841f..30e1e022e 100644
--- a/frontend/src/v2/components/V2AccountMenu.tsx
+++ b/frontend/src/v2/components/V2AccountMenu.tsx
@@ -31,7 +31,13 @@ const V2AccountMenu: React.FC = () => {
onClick={(event) => setAnchorEl(event.currentTarget)}
title="Account"
>
-
+
= ({
className,
].filter(Boolean).join(' ');
- // Character tier (photo still wins, below). Memoized because the SVG build
- // runs per identity per render otherwise, and chat re-renders per message.
- // Falls back to gradient+initials on any generation failure — the character
- // is presentation, never load-bearing.
+ // Default identity tier, used when no image is currently renderable. Human
+ // images and saved Cut picks use the image path above; if an image fails, an
+ // unpicked human falls back to Paper. Agents keep their existing face.
+ // Memoized because SVG generation runs per identity per render otherwise,
+ // and chat re-renders per message.
const characterSrc = React.useMemo(
- () => (kind ? characterAvatarFor(seedProp || seed, kind) : null),
+ () => {
+ if (!kind) return null;
+ if (kind === 'human') return paperAvatarFor(seedProp || seed);
+ return characterAvatarFor(seedProp || seed, kind);
+ },
[kind, seedProp, seed],
);
diff --git a/frontend/src/v2/components/V2SettingsPage.tsx b/frontend/src/v2/components/V2SettingsPage.tsx
index e9d765b6c..05b234464 100644
--- a/frontend/src/v2/components/V2SettingsPage.tsx
+++ b/frontend/src/v2/components/V2SettingsPage.tsx
@@ -110,6 +110,7 @@ const V2AccountSection: React.FC = () => {
className="v2-settings__avatar"
name={name || accountName}
src={currentUser?.profilePicture || undefined}
+ kind="human"
seed={currentUser?._id || currentUser?.id}
title={`${name || accountName} avatar`}
/>
diff --git a/frontend/src/v2/utils/avatarKit.ts b/frontend/src/v2/utils/avatarKit.ts
index 87f9371ce..d61f06838 100644
--- a/frontend/src/v2/utils/avatarKit.ts
+++ b/frontend/src/v2/utils/avatarKit.ts
@@ -33,6 +33,16 @@ export interface FaceTraits {
shirt: string;
}
+export type PaperNeckline = 'crew' | 'vee' | 'scoop';
+
+export interface PaperTraits {
+ /** Hex without '#', from the human avatar background family. */
+ background: string;
+ /** Hex without '#', one of the two Paper shirt greys. */
+ shirt: string;
+ neckline: PaperNeckline;
+}
+
/** A curated picker cell: each field is the set the seed may pick from. */
export interface FaceCell {
skin: readonly string[];
@@ -172,4 +182,25 @@ export const renderFace = (t: FaceTraits): string => {
return ``;
};
+const PAPER_SHOULDERS = 'M8 64C8 51 18 46 32 46C46 46 56 51 56 64Z';
+const PAPER_VEE = 'M27.4 46.5L32 53.6L36.6 46.5Z';
+
+/** The unpicked human default: a paper figure with no facial or skin cues. */
+export const renderPaper = (t: PaperTraits): string => {
+ const neckline = t.neckline === 'vee'
+ ? ``
+ : t.neckline === 'scoop'
+ ? ''
+ : '';
+ return [
+ '',
+ ].join('');
+};
+
export const faceDataUri = (svg: string): string => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
diff --git a/frontend/src/v2/utils/avatars.ts b/frontend/src/v2/utils/avatars.ts
index 13e49b535..618dd3787 100644
--- a/frontend/src/v2/utils/avatars.ts
+++ b/frontend/src/v2/utils/avatars.ts
@@ -1,5 +1,5 @@
import {
- faceTraitsFor, renderFace, faceDataUri, FaceCell, HairStyle, Accessory,
+ faceTraitsFor, renderFace, renderPaper, faceDataUri, FaceCell, HairStyle, Accessory, PaperNeckline,
} from './avatarKit';
// Avatar tints. Blue-forward and cohesive with the design system's single
@@ -161,6 +161,26 @@ const backgroundFor = (key: string, kind: AvatarKind): string => {
return AVATAR_PALETTE[family[hashString(key) % family.length]].base;
};
+const PAPER_NECKLINES: readonly PaperNeckline[] = ['crew', 'vee', 'scoop'];
+const PAPER_SHIRTS = ['e4e7ec', 'd0d5dd'] as const;
+
+/** Seeded 24-look Paper default for people with no stored pick or photo. */
+export const paperAvatarFor = (seed: string | undefined | null): string | null => {
+ const key = String(seed || '').trim();
+ if (!key) return null;
+
+ const variant = hashString(key) % 24;
+ try {
+ return faceDataUri(renderPaper({
+ background: backgroundFor(key, 'human').slice(1),
+ neckline: PAPER_NECKLINES[Math.floor(variant / 4) % PAPER_NECKLINES.length],
+ shirt: PAPER_SHIRTS[Math.floor(variant / 12)],
+ }));
+ } catch {
+ return null;
+ }
+};
+
// ── Deliberate diversity in the picker grid (Sam, 2026-08-21) ───────────────
//
// A grid of 8 random rolls can hand a user 8 similar faces and nothing that
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index ab1326a4c..4a22895b5 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -787,10 +787,17 @@ body.modern-ui.v2-canvas {
}
.v2-rail__account {
+ width: 32px;
+ height: 32px;
padding: 0;
}
.v2-rail__account .v2-avatar {
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: var(--v2-radius-sm);
+ box-shadow: none;
pointer-events: none;
}