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
@@ -10,8 +10,11 @@ import type { User } from '@backspace/shared';
vi.mock('../../stores/spaceStore', () => ({
useSpaceStore: Object.assign(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }),
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }) },
// The card now reads the space roster to derive badges; the stub must
// carry the same shape the store does, or the selectors dereference
// undefined and the failure looks like a component bug.
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }),
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }) },
),
getApiForOrigin: () => ({ uploads: { url: (k: string) => `/uploads/${k}` } }),
resolveUserOrigin: () => 'local',
@@ -1,17 +1,16 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client';
import { useUIStore } from '../../stores/uiStore';
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { loadFederatedMutuals } from '../../utils/mutuals';
import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition';
import { ProfileCard } from '../profile/ProfileCard';
import { getProfileStyle } from '../../utils/profileStyle';
import { useT } from '../../i18n';
/** Gap between the card and the element it was opened from. */
const ANCHOR_OFFSET = 8;
@@ -26,6 +25,7 @@ interface UserProfilePopoutProps {
export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) {
const navigate = useNavigate();
const t = useT();
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const openModal = useUIStore((s) => s.openModal);
// Resolve to the best-known view of this user from the userViews cache.
@@ -119,12 +119,12 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
const bannerSrc = user.banner
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
: null;
const bannerFallback = user.accentColor
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
: (() => {
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
return mutedGradient(g.from, g.to);
})();
const style = getProfileStyle(user);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const members = useSpaceStore((s) => s.members);
const space = spaces.find((sp) => sp.id === currentSpaceId) ?? null;
const member = members.find((m) => m.userId === user.id) ?? null;
// Parked off-screen for the one layout pass before the card knows how tall it
// is; `useLayoutEffect` places it before the browser paints, so it never
@@ -138,81 +138,22 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
style={cardStyle}
>
{/* Banner */}
<div
className="h-[80px] rounded-t-[12px]"
style={bannerSrc
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback }
}
/>
{/* Body */}
<div className="px-4 pb-4 relative">
{/* Avatar */}
{/* The picture escalates to the full profile — the card is a preview, and
clicking the face is the obvious way to ask for the whole thing. It
deliberately does NOT reopen the card (see issue #37). */}
<Avatar
src={user.avatar}
name={displayName}
size={80}
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
userId={user.homeUserId ?? user.id}
user={user}
onClick={handleAvatarClick}
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
className="mt-[-44px] mb-3"
/>
{/* Name & info */}
<div>
<Username
username={user.displayName ?? baseName}
className="text-[16px] font-semibold leading-tight"
/>
<div className="text-[13px] text-txt-tertiary">
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
</div>
{user.customStatus && (
<div className="text-[13px] text-txt-secondary italic mt-1">
{user.customStatus}
</div>
)}
</div>
{/* Bio */}
{user.bio && (
<>
<div className="border-t border-white/[0.06] my-3" />
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
About Me
</span>
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
),
}}
>
{user.bio}
</ReactMarkdown>
</div>
</div>
</>
)}
<ProfileCard
user={user}
style={style}
displayName={displayName}
bannerSrc={bannerSrc}
space={space}
member={member}
onAvatarClick={handleAvatarClick}
>
<div className="border-t border-white/[0.06] my-3" />
{/* Member since + Mutuals */}
<div className="space-y-1.5">
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
Member Since
{t('profile.memberSince')}
</span>
<span className="text-[12px] text-txt-secondary ml-2">
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
@@ -238,15 +179,15 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
onClick={handleSendMessage}
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
>
Send Message
{t('profile.sendMessage')}
</button>
<button
onClick={handleViewFullProfile}
className="w-full mt-1.5 py-2 rounded-lg text-[13px] font-medium text-txt-tertiary hover:text-txt-secondary bg-transparent hover:bg-white/[0.04] transition-colors"
>
View Full Profile
{t('profile.viewFull')}
</button>
</div>
</ProfileCard>
</div>
);
}