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
+3 -1
View File
@@ -18,7 +18,9 @@
"./src/audit": "./src/audit.ts",
"./src/audit.js": "./src/audit.ts",
"./src/mentions": "./src/mentions.ts",
"./src/mentions.js": "./src/mentions.ts"
"./src/mentions.js": "./src/mentions.ts",
"./src/profile": "./src/profile.ts",
"./src/profile.js": "./src/profile.ts"
},
"scripts": {
"build": "tsc",
+250
View File
@@ -0,0 +1,250 @@
/**
* Profile identity: everything a user can style about how they appear, beyond
* the avatar/banner/bio fields that predate it.
*
* Stored as one JSON column (`users.profile_style`) rather than a dozen
* columns: the shape will keep growing (new effects, new widgets), every field
* is optional, and nothing here is ever queried by value. The server
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
* always a complete, bounded, well-formed object.
*
* Catalog ids are strings on purpose — a client that does not know an id
* (older build, other instance) falls back to `none`/`default` instead of
* crashing on an enum it has never seen.
*/
export const NAME_FONTS = [
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
] as const;
export type NameFont = (typeof NAME_FONTS)[number];
export const NAME_EFFECTS = [
'solid', 'gradient', 'neon', 'toon', 'pop', 'gummy', 'prism', 'glitch', 'fire',
] as const;
export type NameEffect = (typeof NAME_EFFECTS)[number];
export const AVATAR_DECORATIONS = [
'none', 'ring', 'neon', 'flames', 'stars', 'halo', 'vines', 'pixel', 'hearts', 'frost', 'crown',
] as const;
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
export const PROFILE_EFFECTS = [
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
] as const;
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
export const NAMEPLATES = [
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight', 'custom',
] as const;
export type Nameplate = (typeof NAMEPLATES)[number];
export interface NameStyle {
font: NameFont;
effect: NameEffect;
/** Two hex colours. Solid uses the first; every other effect uses both. */
colors: [string, string];
}
export interface ProfileTheme {
/** Two hex colours behind the card and the banner fallback. */
colors: [string, string];
}
export interface ProfileTag {
/** 2–4 characters, shown uppercase in a chip next to the name. */
text: string;
/** Single emoji (grapheme) in front of the text; empty for none. */
emoji: string;
color: string;
}
export interface ProfileLink {
label: string;
url: string;
}
export interface GameEntry {
/** Steam app id when picked from search; null for a free-typed title. */
appId: number | null;
name: string;
/** https URL of a cover image (Steam CDN when picked from search). */
cover: string | null;
note: string;
tags: string[];
}
export interface ProfileQuote {
text: string;
author: string;
}
export interface ProfileWidgets {
favoriteGame: GameEntry | null;
rotation: GameEntry[];
wishlist: GameEntry[];
quote: ProfileQuote | null;
}
export interface ProfileStyle {
name: NameStyle;
theme: ProfileTheme | null;
decoration: AvatarDecoration;
effect: ProfileEffect;
nameplate: Nameplate;
/** Only read when nameplate === 'custom'. */
nameplateColors: [string, string];
pronouns: string;
tag: ProfileTag | null;
links: ProfileLink[];
widgets: ProfileWidgets;
}
export const PROFILE_LIMITS = {
pronouns: 40,
tagMin: 2,
tagMax: 4,
links: 5,
linkLabel: 24,
linkUrl: 200,
gameName: 80,
gameNote: 80,
gameTags: 3,
gameTag: 16,
rotation: 5,
wishlist: 10,
quoteText: 140,
quoteAuthor: 40,
} as const;
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
theme: null,
decoration: 'none',
effect: 'none',
nameplate: 'none',
nameplateColors: ['#7c6cf6', '#f472b6'],
pronouns: '',
tag: null,
links: [],
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
};
// ─── Normalisation ────────────────────────────────────────────────────────────
const HEX = /^#[0-9a-fA-F]{6}$/;
/** Domains whose images a profile may embed. Anything else is dropped, not
* proxied — a profile is not a place to load arbitrary third-party pixels. */
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}
function hex(value: unknown, fallback: string): string {
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
}
function str(value: unknown, max: number): string {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
function httpsUrl(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const v = value.trim();
if (v.length === 0 || v.length > max) return null;
try {
const u = new URL(v);
return u.protocol === 'https:' ? u.toString() : null;
} catch { return null; }
}
function coverUrl(value: unknown): string | null {
const u = httpsUrl(value, 300);
if (!u) return null;
try { return COVER_HOSTS.includes(new URL(u).hostname) ? u : null; } catch { return null; }
}
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
function oneGrapheme(value: unknown): string {
if (typeof value !== 'string') return '';
const s = value.trim();
if (!s) return '';
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
if (seg) {
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
}
return Array.from(s)[0] ?? '';
}
function game(value: unknown): GameEntry | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
const name = str(v.name, PROFILE_LIMITS.gameName);
if (!name) return null;
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
const tags = Array.isArray(v.tags)
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
: [];
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
}
function games(value: unknown, max: number): GameEntry[] {
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
}
/**
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
* back to defaults, over-long strings are cut, bad colours are replaced, bad
* URLs are dropped. The server stores exactly this output.
*/
export function normalizeProfileStyle(input: unknown): ProfileStyle {
const d = DEFAULT_PROFILE_STYLE;
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
const nameColors = Array.isArray(name.colors) ? name.colors : [];
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
const links: ProfileLink[] = Array.isArray(v.links)
? v.links.map((l) => {
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
if (!url) return null;
const label = str(o.label, PROFILE_LIMITS.linkLabel) || new URL(url).hostname.replace(/^www\./, '');
return { label, url };
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
: [];
return {
name: {
font: pick(name.font, NAME_FONTS, d.name.font),
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
},
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
tag: tagText.length >= PROFILE_LIMITS.tagMin
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
: null,
links,
widgets: {
favoriteGame: game(widgets.favoriteGame),
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
},
};
}
/** Whether a style differs from the defaults — used to skip rendering work. */
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
}
/** Steam's poster-format cover for an app id. */
export function steamCoverUrl(appId: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
}
+4
View File
@@ -29,6 +29,8 @@ export interface User {
showActivity?: boolean;
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
federationHomeOrphaned?: boolean;
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
profileStyle?: import('./profile').ProfileStyle | null;
}
export interface ReplicatedInstance {
@@ -591,6 +593,8 @@ export interface UpdateUserRequest {
profileUpdatedAt?: number;
discoverable?: boolean;
showActivity?: boolean;
/** Normalised server-side; pass null to reset to defaults. */
profileStyle?: import('./profile').ProfileStyle | null;
}
export interface UpdateMemberRequest {