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 }) => ( - {`${name} + const MockV2Avatar = ({ name, src, className, kind }: { name?: string; src?: string; className?: string; kind?: string }) => ( + {`${name} ); 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 `${paths}`; }; +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 [ + '', + ``, + ``, + '', + '', + neckline, + '', + ].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; }