feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.
What a user can now set, and where it shows:
- Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
- Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
hearts, frost, crown). Inline SVG — no image assets, scales to any size.
- Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
positions so re-renders do not change the weather.
- Nameplate behind the member-list row: 10 presets + custom two-colour.
- Theme: two colours washed behind the card, and the banner fallback.
- Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
also on the member row.
- Links, up to 5, with the brand icon picked from the host.
- Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
Games come from Steam search through a small server proxy (cached,
10 min) or are typed by hand; covers only load from Steam's CDN.
- Badges, derived rather than stored: instance admin, server owner,
founder (joined in the space's first month), veteran (a year), OG (six
months), stylist. Nothing to grant, revoke or migrate.
Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.
The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.
Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.
Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS } from '@backspace/shared/src/profile';
|
||||
import { computeBadges, detectLinkIcon } from './profileStyle';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
describe('normalizeProfileStyle', () => {
|
||||
it('returns defaults for garbage', () => {
|
||||
expect(normalizeProfileStyle(null)).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
expect(normalizeProfileStyle('nope')).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
expect(normalizeProfileStyle({ name: 'x', links: 'y', widgets: 3 })).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
});
|
||||
|
||||
it('falls back on unknown catalog ids instead of throwing', () => {
|
||||
const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'rainbow' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' });
|
||||
expect(s.name.font).toBe('default');
|
||||
expect(s.name.effect).toBe('solid');
|
||||
expect(s.decoration).toBe('none');
|
||||
expect(s.effect).toBe('none');
|
||||
expect(s.nameplate).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects bad colours, lowercases good ones', () => {
|
||||
const s = normalizeProfileStyle({ name: { colors: ['#ABCDEF', 'red'] } });
|
||||
expect(s.name.colors).toEqual(['#abcdef', DEFAULT_PROFILE_STYLE.name.colors[1]]);
|
||||
});
|
||||
|
||||
it('bounds every string', () => {
|
||||
const s = normalizeProfileStyle({ pronouns: 'x'.repeat(500), widgets: { quote: { text: 'q'.repeat(999), author: 'a'.repeat(999) } } });
|
||||
expect(s.pronouns).toHaveLength(PROFILE_LIMITS.pronouns);
|
||||
expect(s.widgets.quote?.text).toHaveLength(PROFILE_LIMITS.quoteText);
|
||||
expect(s.widgets.quote?.author).toHaveLength(PROFILE_LIMITS.quoteAuthor);
|
||||
});
|
||||
|
||||
it('tag: uppercase alphanumerics, 2–4 chars, one emoji grapheme', () => {
|
||||
expect(normalizeProfileStyle({ tag: { text: 'strt!', emoji: '🔥🔥', color: '#ff0000' } }).tag).toEqual({ text: 'STRT', emoji: '🔥', color: '#ff0000' });
|
||||
expect(normalizeProfileStyle({ tag: { text: 'a' } }).tag).toBeNull();
|
||||
expect(normalizeProfileStyle({ tag: { text: 'abcdefg' } }).tag?.text).toBe('ABCD');
|
||||
});
|
||||
|
||||
it('links: https only, capped, label defaults to host', () => {
|
||||
const s = normalizeProfileStyle({ links: [
|
||||
{ label: '', url: 'https://www.twitch.tv/syncwrld' },
|
||||
{ label: 'bad', url: 'http://insecure.example' },
|
||||
{ label: 'js', url: 'javascript:alert(1)' },
|
||||
...Array.from({ length: 10 }, (_, i) => ({ label: `l${i}`, url: `https://x.com/${i}` })),
|
||||
] });
|
||||
expect(s.links).toHaveLength(PROFILE_LIMITS.links);
|
||||
expect(s.links[0]).toEqual({ label: 'twitch.tv', url: 'https://www.twitch.tv/syncwrld' });
|
||||
expect(s.links.some((l) => l.url.startsWith('http:') || l.url.startsWith('javascript'))).toBe(false);
|
||||
});
|
||||
|
||||
it('game covers: only Steam CDN hosts survive', () => {
|
||||
const s = normalizeProfileStyle({ widgets: {
|
||||
favoriteGame: { name: 'CS2', appId: 730, cover: 'https://cdn.cloudflare.steamstatic.com/steam/apps/730/library_600x900.jpg' },
|
||||
rotation: [{ name: 'Sneaky', cover: 'https://evil.example/track.png' }],
|
||||
} });
|
||||
expect(s.widgets.favoriteGame?.cover).toContain('steamstatic');
|
||||
expect(s.widgets.rotation[0]?.cover).toBeNull();
|
||||
expect(s.widgets.rotation[0]?.name).toBe('Sneaky');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' });
|
||||
expect(normalizeProfileStyle(once)).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLinkIcon', () => {
|
||||
it('maps known hosts and subdomains', () => {
|
||||
expect(detectLinkIcon('https://www.twitch.tv/x')).toBe('twitch');
|
||||
expect(detectLinkIcon('https://youtu.be/abc')).toBe('youtube');
|
||||
expect(detectLinkIcon('https://open.spotify.com/user/x')).toBe('spotify');
|
||||
expect(detectLinkIcon('https://steamcommunity.com/id/x')).toBe('steam');
|
||||
expect(detectLinkIcon('https://twitter.com/x')).toBe('x');
|
||||
expect(detectLinkIcon('https://streethosting.com.br')).toBe('link');
|
||||
expect(detectLinkIcon('not a url')).toBe('link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBadges', () => {
|
||||
const now = Date.UTC(2026, 8, 2);
|
||||
const DAY = 86_400_000;
|
||||
const base = { id: 'u1', username: 'u', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, homeInstance: null, homeUserId: null, replicatedInstances: [] } as unknown as User;
|
||||
|
||||
it('derives owner, founder, tenure and stylist from facts already present', () => {
|
||||
const space = { ownerId: 'u1', createdAt: now - 400 * DAY };
|
||||
const owner = computeBadges({ ...base, createdAt: now - 400 * DAY }, { space, now });
|
||||
expect(owner).toEqual(['owner', 'veteran']);
|
||||
|
||||
const founder = computeBadges({ ...base, id: 'u2', createdAt: now - 200 * DAY }, { space, member: { joinedAt: space.createdAt + 5 * DAY }, now });
|
||||
expect(founder).toEqual(['founder', 'og']);
|
||||
|
||||
const late = computeBadges({ ...base, id: 'u3', createdAt: now - 10 * DAY }, { space, member: { joinedAt: space.createdAt + 90 * DAY }, now });
|
||||
expect(late).toEqual([]);
|
||||
|
||||
const styled = computeBadges({ ...base, id: 'u4', createdAt: now, profileStyle: normalizeProfileStyle({ decoration: 'flames' }) }, { now });
|
||||
expect(styled).toEqual(['stylist']);
|
||||
});
|
||||
|
||||
it('admin comes first', () => {
|
||||
expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user