Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/src/v2/__tests__/V2AccountMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthContext.Provider value={auth}>
<MemoryRouter>
<div className="v2-root"><V2AccountMenu /></div>
</MemoryRouter>
</AuthContext.Provider>,
);

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/);
});
});
43 changes: 42 additions & 1 deletion frontend/src/v2/__tests__/V2Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -43,4 +47,41 @@ describe('V2Avatar', () => {
rerender(<V2Avatar name="Ada renamed" seed="identity-1" kind={kind} tone="flat" />);
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(
<V2Avatar name="Ada" src="default" kind="human" seed="paper-user" />,
);

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(
<V2Avatar name="Ada" src="bigsmile:paper-user-v13" kind="human" seed="paper-user" />,
);
image = screen.getByRole('img', { name: 'Ada' });
svg = svgFromDataUri(image.getAttribute('src'));
expect(svg).toContain('fill="#e2ba87"');

rerender(
<V2Avatar name="Ada" src="/api/uploads/ada.png" kind="human" seed="paper-user" />,
);
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(
<V2Avatar name="Ada" src="/api/uploads/ada.png" kind="human" seed="paper-user" />,
);

fireEvent.error(screen.getByRole('img', { name: 'Ada' }));

const svg = svgFromDataUri(screen.getByRole('img', { name: 'Ada' }).getAttribute('src'));
expect(svg).toContain('<ellipse cx="32" cy="29" rx="13" ry="15" fill="#f9fafb"/>');
expect(svg).not.toMatch(/fill="#(?:ffe4c0|f5d7b1|efcc9f|e2ba87|c99c62|a47539|8c5a2b|643d19)"/);
});
});
5 changes: 3 additions & 2 deletions frontend/src/v2/__tests__/V2SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ jest.mock('../../components/AppsManagement', () => {
});

jest.mock('../components/V2Avatar', () => {
const MockV2Avatar = ({ name, src, className }: { name?: string; src?: string; className?: string }) => (
<img alt={`${name} avatar`} className={className} src={src} />
const MockV2Avatar = ({ name, src, className, kind }: { name?: string; src?: string; className?: string; kind?: string }) => (
<img alt={`${name} avatar`} className={className} data-kind={kind} src={src} />
);
MockV2Avatar.displayName = 'MockV2Avatar';
return MockV2Avatar;
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 26 additions & 4 deletions frontend/src/v2/__tests__/avatarCharacter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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('<rect x="27" y="38" width="10" height="10" fill="#f9fafb"/>');
expect(svg).not.toMatch(/<circle|<text|<image|<style|<script/);
}
});

test('a stored human picker cell still renders a Cut face with its selected skin', () => {
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
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -873,6 +887,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(settingsPage).toContain('<SettingsSection id="language" title="Language">');
expect(settingsPage).toContain('className="v2-settings__avatar"');
expect(settingsPage).toContain('src={currentUser?.profilePicture || undefined}');
expect(settingsPage).toContain('kind="human"');
expect(settingsPage).toContain('<AppsManagement variant="settings" />');
expect(settingsPage).toContain('<V2BillingPanel showHeading={false} />');
expect(settingsPage).toContain('<V2DevicesPanel showHeading={false} />');
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/v2/components/V2AccountMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ const V2AccountMenu: React.FC = () => {
onClick={(event) => setAnchorEl(event.currentTarget)}
title="Account"
>
<V2Avatar name={currentUser?.username || 'You'} size="md" online />
<V2Avatar
name={currentUser?.username || 'You'}
src={currentUser?.profilePicture}
size="md"
kind="human"
seed={currentUser?._id || currentUser?.id}
/>
</button>
<Popover
open={open}
Expand Down
29 changes: 17 additions & 12 deletions frontend/src/v2/components/V2Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { getAvatarSrc } from '../../utils/avatarUtils';
import {
characterAvatarFor, gradientFor, initialsFor, AvatarKind,
characterAvatarFor, paperAvatarFor, gradientFor, initialsFor, AvatarKind,
} from '../utils/avatars';

export type V2AvatarSize = 'sm' | 'md' | 'lg';
Expand All @@ -14,13 +14,13 @@ interface V2AvatarProps {
online?: boolean;
title?: string;
/**
* Renders the character tier: Commonly's own faces for both kinds (the
* "Cut" kit, avatarKit.ts), with the species carried by disjoint background
* families ('human' warm, 'agent' cool) and by dress (agents in ink with a
* cobalt collar). Omitted → gradient+initials, unchanged
* Renders the local identity tier: unpicked humans get Paper, while saved
* character picks and agents keep the Cut kit. Species stays legible through
* disjoint background families ('human' warm, 'agent' cool) and dress
* (agents in ink with a cobalt collar). Omitted → gradient+initials, unchanged
* — callers that cannot tell who they are drawing must not guess, because
* mislabelling the tier mislabels the PERSON's species tint.
* An uploaded photo always wins over both tiers.
* mislabelling the tier mislabels the PERSON's species tint. A loadable
* uploaded photo wins over both tiers; failed human images fall back to Paper.
*/
kind?: AvatarKind;
/**
Expand Down Expand Up @@ -73,12 +73,17 @@ const V2Avatar: React.FC<V2AvatarProps> = ({
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],
);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/v2/components/V2SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`}
/>
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/v2/utils/avatarKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -172,4 +182,25 @@ export const renderFace = (t: FaceTraits): string => {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${paths}</svg>`;
};

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'
? `<path d="${PAPER_VEE}" fill="#f9fafb"/>`
: t.neckline === 'scoop'
? '<ellipse cx="32" cy="47.2" rx="5.4" ry="3.4" fill="#f9fafb"/>'
: '';
return [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">',
`<path d="M0 0h64v64H0z" fill="#${t.background}"/>`,
`<path d="${PAPER_SHOULDERS}" fill="#${t.shirt}"/>`,
'<rect x="27" y="38" width="10" height="10" fill="#f9fafb"/>',
'<ellipse cx="32" cy="29" rx="13" ry="15" fill="#f9fafb"/>',
neckline,
'</svg>',
].join('');
};

export const faceDataUri = (svg: string): string => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
22 changes: 21 additions & 1 deletion frontend/src/v2/utils/avatars.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading