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

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:
2026-09-02 23:36:23 -03:00
co-authored by Claude Fable 5.1
parent 872066e5be
commit ef9c73501c
44 changed files with 6441 additions and 116 deletions
+103
View File
@@ -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']);
});
});