fix(profile): align the decorations, and make the effects actually look like something
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

Three complaints, three causes.

Decorations sat off-centre. <Avatar>'s box is `size + 2 × ringWidth`, but the
overlay was positioned with a percentage inset of that box — so it drifted by
exactly the ring whenever one was present. That is why the crown floated above
the head on the profile card while looking fine on the editor tiles, which
have no ring. DecoratedAvatar now takes `size` and `ringWidth` and computes
the inset in pixels, so the picture's edge always lands at a known radius in
the SVG viewBox and every decoration is drawn against it.

The ugly ones were ugly. Flames were a blob with a black circle punched out of
it; they are now fourteen tongues of alternating height on a gradient. The
crown's band sits ON the picture's top edge instead of hovering above it.
Frost, stars and hearts were placed by eye and are now on a computed ring.
Nine more: holo disc (a conic gradient — the honest way to draw one, and CSS
has it where SVG does not), chain, cyber brackets, lightning, spikes,
butterflies, sakura, bubbles, flower. Twenty in total.

Name effects were thin. Gradient was a two-stop ramp; it now has a lit middle.
Neon was one shadow, now five layered with a buzz. Glitch was a keyframed
text-shadow, now a real RGB split duplicated through ::before/::after from
data-text. Added chrome and gold (banded metal with a specular line — the y2k
look the reference is after), holo, rainbow, ice, 3D extrude, and outline.
Sixteen effects, thirteen fonts with Audiowide, Bungee and Monoton added.

The editor tiles now sample the user's own name rather than "Gg" or the effect
label: an effect reads differently on a short word than on the name it will
carry. Two per row instead of three, and section rows gained an icon.

One test failed on the catalog growth and was right to: it used 'rainbow' as
an example of an unknown id, and rainbow is now a real effect. It uses an
absurd id instead, and a new test walks every catalog id through the
normaliser so a catalog and the normaliser can never drift apart silently.

Verified: shared build, web build, server typecheck, 518 web tests (1 new).
Not verified: pixels — no browser here; the geometry is now computed rather
than guessed, but a screenshot decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:46:20 -03:00
co-authored by Claude Opus 5
parent 723522b90d
commit e6eba7a2b7
12 changed files with 551 additions and 131 deletions
+5 -2
View File
@@ -15,16 +15,19 @@
export const NAME_FONTS = [ export const NAME_FONTS = [
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker', 'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
'techno', 'block', 'retro',
] as const; ] as const;
export type NameFont = (typeof NAME_FONTS)[number]; export type NameFont = (typeof NAME_FONTS)[number];
export const NAME_EFFECTS = [ export const NAME_EFFECTS = [
'solid', 'gradient', 'neon', 'toon', 'pop', 'gummy', 'prism', 'glitch', 'fire', 'solid', 'gradient', 'chrome', 'gold', 'holo', 'neon', 'toon', 'pop', 'gummy',
'prism', 'glitch', 'fire', 'ice', 'shadow', 'outline', 'rainbow',
] as const; ] as const;
export type NameEffect = (typeof NAME_EFFECTS)[number]; export type NameEffect = (typeof NAME_EFFECTS)[number];
export const AVATAR_DECORATIONS = [ export const AVATAR_DECORATIONS = [
'none', 'ring', 'neon', 'flames', 'stars', 'halo', 'vines', 'pixel', 'hearts', 'frost', 'crown', 'none', 'ring', 'neon', 'cd', 'chain', 'cyber', 'pixel', 'flames', 'lightning', 'spikes',
'frost', 'stars', 'hearts', 'butterfly', 'sakura', 'vines', 'bubbles', 'crown', 'halo', 'flower',
] as const; ] as const;
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number]; export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -311,7 +311,7 @@ export function UserProfileModal() {
{/* Header (avatar + name) */} {/* Header (avatar + name) */}
<div className="px-5 flex-shrink-0 relative"> <div className="px-5 flex-shrink-0 relative">
<div className="flex items-end justify-between mt-[-52px] mb-2"> <div className="flex items-end justify-between mt-[-52px] mb-2">
<DecoratedAvatar decoration={style.decoration}> <DecoratedAvatar decoration={style.decoration} size={96} ringWidth={4}>
<Avatar <Avatar
src={user.avatar} src={user.avatar}
name={displayName} name={displayName}
@@ -60,7 +60,19 @@ function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v:
} }
const tileClass = (active: boolean) => const tileClass = (active: boolean) =>
`relative rounded-xl border-2 p-3 flex flex-col items-center gap-2 transition-colors text-center ${active ? 'border-accent-primary bg-accent-primary/10' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06]'}`; `group/tile relative rounded-xl border-2 p-3 flex flex-col items-center gap-2 transition-all text-center ${active ? 'border-accent-primary bg-accent-primary/10 shadow-[0_0_0_3px_rgba(124,108,246,0.15)]' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/[0.14]'}`;
/** One glyph per section, so the list scans by shape rather than by reading. */
const SECTION_ICON: Record<string, string> = {
nameplate: 'M3 6h18v4H3V6zm0 6h12v2H3v-2zm0 4h8v2H3v-2z',
avatar: 'M12 12c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zm0 2c-2.7 0-8 1.3-8 4v2h16v-2c0-2.7-5.3-4-8-4z',
name: 'M5 4v3h5.5v12h3V7H19V4H5z',
theme: 'M12 3a9 9 0 1 0 0 18c.8 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1-.3-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16a5 5 0 0 0 5-5c0-4.4-4-8-9-8zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z',
effect: 'M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4L12 2z',
identity: 'M12 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0 12c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5z',
links: 'M3.9 12a4.1 4.1 0 0 1 4.1-4.1h4V6H8a6 6 0 0 0 0 12h4v-1.9H8A4.1 4.1 0 0 1 3.9 12zM8 13h8v-2H8v2zm8-7h-4v1.9h4a4.1 4.1 0 0 1 0 8.2h-4V18h4a6 6 0 0 0 0-12z',
board: 'M4 4h7v7H4V4zm9 0h7v4h-7V4zm0 6h7v10h-7V10zM4 13h7v7H4v-7z',
};
const chipClass = (active: boolean) => const chipClass = (active: boolean) =>
`px-3 py-2 rounded-lg text-sm border transition-colors ${active ? 'border-accent-primary bg-accent-primary/15 text-txt-primary' : 'border-white/[0.06] bg-white/[0.03] text-txt-secondary hover:bg-white/[0.06]'}`; `px-3 py-2 rounded-lg text-sm border transition-colors ${active ? 'border-accent-primary bg-accent-primary/15 text-txt-primary' : 'border-white/[0.06] bg-white/[0.03] text-txt-secondary hover:bg-white/[0.06]'}`;
@@ -177,7 +189,7 @@ export function ProfilePanel() {
), ),
avatar: ( avatar: (
<div className="flex items-center gap-4 py-1"> <div className="flex items-center gap-4 py-1">
<DecoratedAvatar decoration={draft.decoration}> <DecoratedAvatar decoration={draft.decoration} size={48}>
<Avatar src={user.avatar} name={displayName} size={48} userId={user.homeUserId ?? user.id} user={user} /> <Avatar src={user.avatar} name={displayName} size={48} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar> </DecoratedAvatar>
<span className="text-xs text-txt-tertiary">{t(`profile.decoration.${draft.decoration}` as TranslationKey)}</span> <span className="text-xs text-txt-tertiary">{t(`profile.decoration.${draft.decoration}` as TranslationKey)}</span>
@@ -225,10 +237,10 @@ export function ProfilePanel() {
</> </>
), ),
avatar: ( avatar: (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2.5">
{AVATAR_DECORATIONS.map((d) => ( {AVATAR_DECORATIONS.map((d) => (
<button key={d} type="button" onClick={() => set('decoration', d)} className={tileClass(draft.decoration === d)}> <button key={d} type="button" onClick={() => set('decoration', d)} className={tileClass(draft.decoration === d)}>
<DecoratedAvatar decoration={d}> <DecoratedAvatar decoration={d} size={56}>
<Avatar src={user.avatar} name={displayName} size={56} userId={user.homeUserId ?? user.id} user={user} /> <Avatar src={user.avatar} name={displayName} size={56} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar> </DecoratedAvatar>
<span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${d}` as TranslationKey)}</span> <span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${d}` as TranslationKey)}</span>
@@ -242,18 +254,22 @@ export function ProfilePanel() {
<div className="grid grid-cols-3 gap-2 mb-5"> <div className="grid grid-cols-3 gap-2 mb-5">
{NAME_FONTS.map((f) => ( {NAME_FONTS.map((f) => (
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tileClass(draft.name.font === f)}> <button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tileClass(draft.name.font === f)}>
<span className={`name-font-${f} text-[26px] leading-none text-txt-primary`}>Gg</span> <span className={`name-font-${f} text-[24px] leading-none text-txt-primary`}>Ag</span>
<span className="text-[11px] text-txt-secondary">{t(`profile.font.${f}` as TranslationKey)}</span> <span className="text-[11px] text-txt-secondary">{t(`profile.font.${f}` as TranslationKey)}</span>
</button> </button>
))} ))}
</div> </div>
<Label>{t('profile.editor.effect')}</Label> <Label>{t('profile.editor.effect')}</Label>
<div className="grid grid-cols-3 gap-2 mb-5"> <div className="grid grid-cols-2 gap-2 mb-5">
{NAME_EFFECTS.map((e) => { {NAME_EFFECTS.map((e) => {
const p = nameStyleProps({ font: draft.name.font, effect: e, colors: draft.name.colors }); const p = nameStyleProps({ font: draft.name.font, effect: e, colors: draft.name.colors });
const label = t(`profile.effect.${e}` as TranslationKey);
return ( return (
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={`${chipClass(draft.name.effect === e)} text-center`}> <button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={tileClass(draft.name.effect === e)}>
<span className={`${p.className} text-[15px] font-bold`} style={p.style}>{t(`profile.effect.${e}` as TranslationKey)}</span> {/* The sample is the user's own name: an effect looks different
on a short label than on the name it will actually carry. */}
<span className={`${p.className} text-[17px] font-bold truncate max-w-full`} style={p.style} data-text={displayName}>{displayName}</span>
<span className="text-[11px] text-txt-secondary">{label}</span>
</button> </button>
); );
})} })}
@@ -370,10 +386,13 @@ export function ProfilePanel() {
<div className="space-y-2"> <div className="space-y-2">
{SECTIONS.map((id) => ( {SECTIONS.map((id) => (
<button key={id} type="button" onClick={() => setSection(id)} <button key={id} type="button" onClick={() => setSection(id)}
className="w-full text-left rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/[0.05] p-3 transition-colors group"> className="w-full text-left rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/[0.05] hover:border-white/[0.12] p-3 transition-colors group">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-2 mb-2.5">
<span className="text-[13px] font-semibold text-txt-primary">{t(`profile.section.${id}` as TranslationKey)}</span> <span className="w-6 h-6 rounded-md bg-white/[0.06] flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary group-hover:text-txt-secondary"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" className="text-txt-secondary"><path d={SECTION_ICON[id]} /></svg>
</span>
<span className="text-[13px] font-semibold text-txt-primary flex-1">{t(`profile.section.${id}` as TranslationKey)}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary group-hover:text-txt-secondary group-hover:translate-x-0.5 transition-transform"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
</div> </div>
{sectionPreview[id]} {sectionPreview[id]}
</button> </button>
@@ -1,24 +1,46 @@
import React from 'react'; import React from 'react';
import type { AvatarDecoration } from '@backspace/shared/src/profile'; import type { AvatarDecoration } from '@backspace/shared/src/profile';
/**
* How much wider the decoration layer is than the avatar *picture*.
* Everything below is drawn in a 100×100 viewBox mapped onto that layer, so the
* picture's edge always lands at radius AVATAR_R — decorations hug it instead of
* guessing.
*/
const SCALE = 1.42;
const AVATAR_R = 50 / SCALE; // ≈ 35.2
interface DecoratedAvatarProps { interface DecoratedAvatarProps {
decoration: AvatarDecoration; decoration: AvatarDecoration;
/** Avatar element to decorate; the overlay sizes itself to this box. */ /** Diameter of the avatar picture in px — the `size` given to <Avatar>. */
size: number;
/** Ring width given to <Avatar>, if any. Avatar's box grows by 2× this. */
ringWidth?: number;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
/** /**
* Wraps an avatar with a decorative layer. Every decoration is inline SVG on a * Wraps an avatar with a decorative frame.
* 100×100 box around the picture — no image assets, so a new one is a few *
* lines of markup and scales to any avatar size. * The overlay is placed from the avatar's real geometry rather than a
* percentage: <Avatar>'s box is `size + 2 × ringWidth`, so a percentage inset
* drifted whenever a ring was present — which is why decorations sat off-centre
* on the profile card while looking fine on the editor tiles.
*
* Every decoration is inline SVG (or, for `cd`, a CSS conic gradient), so adding
* one is markup rather than an image asset, and it stays sharp at any size.
*/ */
export function DecoratedAvatar({ decoration, children, className = '' }: DecoratedAvatarProps) { export function DecoratedAvatar({ decoration, size, ringWidth = 0, children, className = '' }: DecoratedAvatarProps) {
const box = size + ringWidth * 2;
const overlay = size * SCALE;
const inset = (box - overlay) / 2; // negative: the layer overhangs the box
return ( return (
<div className={`relative inline-block ${className}`}> <div className={`relative inline-flex flex-shrink-0 ${className}`} style={{ width: box, height: box }}>
{children} {children}
{decoration !== 'none' && ( {decoration !== 'none' && (
<div className="avatar-deco" aria-hidden="true"> <div className="absolute pointer-events-none" style={{ inset, zIndex: 2 }} aria-hidden="true">
<Decoration id={decoration} /> <Decoration id={decoration} />
</div> </div>
)} )}
@@ -26,106 +48,282 @@ export function DecoratedAvatar({ decoration, children, className = '' }: Decora
); );
} }
/** Points spread evenly around the avatar edge, for repeat-built decorations. */
function ring(count: number, radius: number, offsetDeg = 0) {
return Array.from({ length: count }, (_, i) => {
const deg = offsetDeg + (i / count) * 360;
const rad = (deg - 90) * (Math.PI / 180);
return { i, deg, x: 50 + Math.cos(rad) * radius, y: 50 + Math.sin(rad) * radius };
});
}
const SVG = { viewBox: '0 0 100 100', className: 'w-full h-full overflow-visible' } as const;
function Decoration({ id }: { id: AvatarDecoration }) { function Decoration({ id }: { id: AvatarDecoration }) {
switch (id) { switch (id) {
// ── Rings ──────────────────────────────────────────────────────────────
case 'ring': case 'ring':
return ( return (
<svg viewBox="0 0 100 100" className="deco-spin-slow"> <svg {...SVG}>
<defs> <defs>
<linearGradient id="deco-ring-g" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="d-ring" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#a78bfa" /><stop offset="0.5" stopColor="#f472b6" /><stop offset="1" stopColor="#fbbf24" /> <stop offset="0" stopColor="#c4b5fd" /><stop offset=".5" stopColor="#f472b6" /><stop offset="1" stopColor="#fbbf24" />
</linearGradient> </linearGradient>
</defs> </defs>
<circle cx="50" cy="50" r="46" fill="none" stroke="url(#deco-ring-g)" strokeWidth="4" /> <circle className="deco-spin-slow" style={{ transformOrigin: '50% 50%' }} cx="50" cy="50" r={AVATAR_R + 2.6} fill="none" stroke="url(#d-ring)" strokeWidth="4" />
</svg> </svg>
); );
case 'neon': case 'neon':
return ( return (
<svg viewBox="0 0 100 100" className="deco-pulse"> <svg {...SVG} className={`${SVG.className} deco-pulse`}>
<circle cx="50" cy="50" r="46" fill="none" stroke="#22d3ee" strokeWidth="3" style={{ filter: 'drop-shadow(0 0 4px #22d3ee) drop-shadow(0 0 10px #a855f7)' }} /> <circle cx="50" cy="50" r={AVATAR_R + 2} fill="none" stroke="#22d3ee" strokeWidth="2.5" style={{ filter: 'drop-shadow(0 0 3px #22d3ee) drop-shadow(0 0 8px #22d3ee)' }} />
<circle cx="50" cy="50" r={AVATAR_R + 5.5} fill="none" stroke="#a855f7" strokeWidth="1.2" opacity="0.75" style={{ filter: 'drop-shadow(0 0 6px #a855f7)' }} />
</svg> </svg>
); );
case 'flames':
case 'chain':
return ( return (
<svg viewBox="0 0 100 100" className="deco-flicker" style={{ transformOrigin: '50% 100%' }}> <svg {...SVG} className={`${SVG.className} deco-spin-slow`} style={{ transformOrigin: '50% 50%' }}>
<defs> {ring(14, AVATAR_R + 3).map((p) => (
<linearGradient id="deco-fire-g" x1="0" y1="1" x2="0" y2="0"> <ellipse key={p.i} cx={p.x} cy={p.y} rx="3.6" ry="2.4" fill="none" stroke={p.i % 2 ? '#e2e8f0' : '#94a3b8'} strokeWidth="1.6" transform={`rotate(${p.deg} ${p.x} ${p.y})`} />
<stop offset="0" stopColor="#ef4444" /><stop offset="0.6" stopColor="#f97316" /><stop offset="1" stopColor="#fde047" />
</linearGradient>
</defs>
<path fill="url(#deco-fire-g)" opacity="0.95"
d="M50 2 C56 12 62 14 60 24 C68 20 72 12 74 6 C78 18 86 22 86 34 C92 30 94 24 94 20 C98 34 98 48 92 60 C98 62 100 56 100 52 C98 74 84 92 64 98 C50 100 36 98 26 90 C12 82 2 66 4 48 C6 44 10 46 12 50 C10 38 14 30 20 24 C22 30 26 34 30 34 C28 22 34 12 42 6 C44 12 46 14 50 14 Z" />
<circle cx="50" cy="52" r="42" fill="#0b0b10" />
</svg>
);
case 'stars':
return (
<svg viewBox="0 0 100 100" className="deco-spin">
{[0, 72, 144, 216, 288].map((a) => (
<g key={a} transform={`rotate(${a} 50 50)`}>
<path transform="translate(50 6) scale(0.55)" fill="#fde68a" stroke="#f59e0b" strokeWidth="1"
d="M0 -10 L2.9 -3.1 L10 -3.1 L4.2 1.2 L6.2 8.1 L0 4 L-6.2 8.1 L-4.2 1.2 L-10 -3.1 L-2.9 -3.1 Z" />
</g>
))} ))}
</svg> </svg>
); );
case 'halo':
case 'cyber':
return ( return (
<svg viewBox="0 0 100 100" className="deco-float"> <svg {...SVG}>
<ellipse cx="50" cy="8" rx="30" ry="7" fill="none" stroke="#fde68a" strokeWidth="4" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }} /> <circle cx="50" cy="50" r={AVATAR_R + 2} fill="none" stroke="#22d3ee" strokeWidth="1" opacity="0.35" strokeDasharray="2 4" />
</svg> {[0, 90, 180, 270].map((a) => (
); <g key={a} transform={`rotate(${a} 50 50)`} stroke="#22d3ee" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ filter: 'drop-shadow(0 0 4px #22d3ee)' }}>
case 'vines': <path d={`M ${50 - 13} ${50 - AVATAR_R - 5} H ${50 - 19} V ${50 - AVATAR_R + 1}`} />
return (
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="46" fill="none" stroke="#166534" strokeWidth="3.5" />
{[20, 70, 130, 200, 260, 320].map((a) => (
<g key={a} transform={`rotate(${a} 50 50) translate(50 4)`}>
<path d="M0 0 C-6 -4 -8 4 -3 8 C2 4 4 -2 0 0 Z" fill="#22c55e" />
<path d="M0 0 C6 -4 8 4 3 8 C-2 4 -4 -2 0 0 Z" fill="#4ade80" />
</g> </g>
))} ))}
{ring(4, AVATAR_R + 7, 45).map((p) => (
<rect key={p.i} x={p.x - 1.7} y={p.y - 1.7} width="3.4" height="3.4" fill="#d946ef" transform={`rotate(45 ${p.x} ${p.y})`} style={{ filter: 'drop-shadow(0 0 3px #d946ef)' }} />
))}
</svg> </svg>
); );
case 'pixel': case 'pixel':
return ( return (
<svg viewBox="0 0 100 100" shapeRendering="crispEdges"> <svg {...SVG} shapeRendering="crispEdges">
{Array.from({ length: 28 }).map((_, i) => { {ring(24, AVATAR_R + 3.5).map((p) => (
const a = (i / 28) * Math.PI * 2; <rect key={p.i} x={p.x - 2.4} y={p.y - 2.4} width="4.8" height="4.8" fill={p.i % 3 === 0 ? '#22d3ee' : p.i % 3 === 1 ? '#a855f7' : '#f472b6'} />
const x = 50 + Math.cos(a) * 45; const y = 50 + Math.sin(a) * 45;
return <rect key={i} x={Math.round(x / 5) * 5 - 2.5} y={Math.round(y / 5) * 5 - 2.5} width="5" height="5" fill={i % 2 ? '#a855f7' : '#22d3ee'} />;
})}
</svg>
);
case 'hearts':
return (
<svg viewBox="0 0 100 100" className="deco-float">
{[[8, 20, 1], [88, 14, 0.8], [6, 74, 0.7], [90, 78, 0.9]].map(([x, y, s], i) => (
<path key={i} transform={`translate(${x} ${y}) scale(${s})`} fill="#fb7185"
d="M0 6 C-8 -2 -8 -10 -2 -10 C1 -10 0 -8 0 -6 C0 -8 -1 -10 2 -10 C8 -10 8 -2 0 6 Z" />
))} ))}
</svg> </svg>
); );
case 'cd':
// The one non-SVG decoration: a conic gradient is the honest way to draw a
// holographic disc, and CSS has one where SVG does not.
return (
<div
className="w-full h-full rounded-full deco-spin-slow"
style={{
background: 'conic-gradient(from 0deg, #22d3ee, #a78bfa, #f472b6, #fbbf24, #4ade80, #22d3ee)',
WebkitMaskImage: `radial-gradient(circle, transparent ${AVATAR_R + 1}%, #000 ${AVATAR_R + 2.5}%, #000 ${AVATAR_R + 7}%, transparent ${AVATAR_R + 8.5}%)`,
maskImage: `radial-gradient(circle, transparent ${AVATAR_R + 1}%, #000 ${AVATAR_R + 2.5}%, #000 ${AVATAR_R + 7}%, transparent ${AVATAR_R + 8.5}%)`,
filter: 'saturate(1.35) drop-shadow(0 0 6px rgba(167,139,250,0.55))',
}}
/>
);
// ── Radiating ──────────────────────────────────────────────────────────
case 'flames':
return (
<svg {...SVG}>
<defs>
<linearGradient id="d-flame" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="#dc2626" /><stop offset=".45" stopColor="#f97316" /><stop offset="1" stopColor="#fde047" />
</linearGradient>
</defs>
<g className="deco-flicker" style={{ transformOrigin: '50% 50%' }}>
{ring(14, AVATAR_R).map((p) => (
<path key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.deg})`} fill="url(#d-flame)" opacity={p.i % 2 ? 0.95 : 0.72}
d={`M -4 1 C -5.5 -5 -2 -7 0 ${p.i % 2 ? -15 : -11} C 2 -7 5.5 -5 4 1 Z`} />
))}
</g>
</svg>
);
case 'spikes':
return (
<svg {...SVG} className={`${SVG.className} deco-spin-slow`} style={{ transformOrigin: '50% 50%' }}>
{ring(16, AVATAR_R).map((p) => (
<path key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.deg})`} fill="#e2e8f0" stroke="#64748b" strokeWidth="0.6" strokeLinejoin="round"
d="M -3 1 L 0 -9 L 3 1 Z" />
))}
</svg>
);
case 'frost': case 'frost':
return ( return (
<svg viewBox="0 0 100 100" className="deco-spin-slow"> <svg {...SVG} className={`${SVG.className} deco-spin-slow`} style={{ transformOrigin: '50% 50%' }}>
<circle cx="50" cy="50" r="46" fill="none" stroke="#bae6fd" strokeWidth="2" strokeDasharray="3 5" opacity="0.9" /> <circle cx="50" cy="50" r={AVATAR_R + 2.5} fill="none" stroke="#bae6fd" strokeWidth="1.2" strokeDasharray="2 5" opacity="0.85" />
{[0, 60, 120, 180, 240, 300].map((a) => ( {ring(8, AVATAR_R + 1).map((p) => (
<g key={a} transform={`rotate(${a} 50 50) translate(50 3)`} stroke="#e0f2fe" strokeWidth="1.6" strokeLinecap="round" style={{ filter: 'drop-shadow(0 0 3px #7dd3fc)' }}> <g key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.deg})`} stroke="#e0f2fe" strokeWidth="1.4" strokeLinecap="round" style={{ filter: 'drop-shadow(0 0 3px #7dd3fc)' }}>
<line x1="0" y1="0" x2="0" y2="10" /><line x1="-4" y1="3" x2="4" y2="7" /><line x1="4" y1="3" x2="-4" y2="7" /> <line x1="0" y1="0" x2="0" y2="-9" />
<line x1="-3" y1="-3" x2="3" y2="-6" />
<line x1="3" y1="-3" x2="-3" y2="-6" />
</g> </g>
))} ))}
</svg> </svg>
); );
case 'crown':
case 'lightning':
return ( return (
<svg viewBox="0 0 100 100" className="deco-float"> <svg {...SVG}>
<path transform="translate(50 6)" fill="#fbbf24" stroke="#b45309" strokeWidth="1.5" strokeLinejoin="round" <g className="deco-flicker" style={{ transformOrigin: '50% 50%' }}>
d="M-22 6 L-28 -12 L-14 -2 L0 -16 L14 -2 L28 -12 L22 6 Z" /> {ring(6, AVATAR_R + 1).map((p) => (
<rect x="28" y="12" width="44" height="5" rx="1.5" fill="#f59e0b" stroke="#b45309" strokeWidth="1" /> <path key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.deg})`} fill="#fde047" stroke="#f59e0b" strokeWidth="0.5" strokeLinejoin="round"
<circle cx="36" cy="-4" r="2.4" fill="#f87171" /><circle cx="50" cy="-8" r="2.4" fill="#60a5fa" /><circle cx="64" cy="-4" r="2.4" fill="#4ade80" /> style={{ filter: 'drop-shadow(0 0 4px #facc15)' }}
d="M 1 0 L -2.5 -6 L 0.5 -6 L -1.5 -12 L 3 -5.5 L 0 -5.5 Z" />
))}
</g>
</svg> </svg>
); );
// ── Scattered accents ──────────────────────────────────────────────────
case 'stars': {
const sparkle = 'M 0 -7 Q 1.1 -1.2 7 0 Q 1.1 1.2 0 7 Q -1.1 1.2 -7 0 Q -1.1 -1.2 0 -7 Z';
const pts = [
{ x: 50, y: 50 - AVATAR_R - 5, s: 1, d: '0s' },
{ x: 50 + 27, y: 50 - 24, s: 0.7, d: '.5s' },
{ x: 50 - 28, y: 50 - 20, s: 0.85, d: '1s' },
{ x: 50 + 30, y: 50 + 16, s: 0.6, d: '1.5s' },
{ x: 50 - 26, y: 50 + 24, s: 0.75, d: '.8s' },
{ x: 50 + 8, y: 50 + AVATAR_R + 6, s: 0.55, d: '1.9s' },
];
return (
<svg {...SVG}>
{pts.map((p, i) => (
<path key={i} className="deco-twinkle" style={{ animationDelay: p.d, transformOrigin: `${p.x}px ${p.y}px` }}
transform={`translate(${p.x} ${p.y}) scale(${p.s})`} fill="#fef9c3" stroke="#fbbf24" strokeWidth="0.7" d={sparkle} />
))}
</svg>
);
}
case 'hearts': {
const heart = 'M 0 4.5 C -6.5 -1.5 -6.5 -8 -2.6 -8 C -1 -8 0 -6.6 0 -5.4 C 0 -6.6 1 -8 2.6 -8 C 6.5 -8 6.5 -1.5 0 4.5 Z';
const pts = [
{ x: 50 - 26, y: 50 - 24, s: 0.9, c: '#fb7185' },
{ x: 50 + 27, y: 50 - 20, s: 0.75, c: '#f472b6' },
{ x: 50 + 29, y: 50 + 18, s: 0.85, c: '#fda4af' },
{ x: 50 - 28, y: 50 + 20, s: 0.65, c: '#f472b6' },
{ x: 50, y: 50 - AVATAR_R - 6, s: 0.7, c: '#fb7185' },
];
return (
<svg {...SVG}>
{pts.map((p, i) => (
<path key={i} className="deco-float" style={{ animationDelay: `${i * 0.35}s` }} transform={`translate(${p.x} ${p.y}) scale(${p.s})`} fill={p.c} d={heart} />
))}
</svg>
);
}
case 'butterfly': {
const wing = 'M 0 0 C -7 -9 -14 -7 -13 -1 C -12.4 3 -6 4.4 0 0 C 6 4.4 12.4 3 13 -1 C 14 -7 7 -9 0 0 Z';
return (
<svg {...SVG}>
{[
{ x: 50 + 26, y: 50 - 25, r: -20, s: 0.85, a: '#a78bfa', b: '#f472b6' },
{ x: 50 - 27, y: 50 + 20, r: 155, s: 0.7, a: '#22d3ee', b: '#a78bfa' },
].map((p, i) => (
<g key={i} className="deco-float" style={{ animationDelay: `${i * 0.6}s` }} transform={`translate(${p.x} ${p.y}) rotate(${p.r}) scale(${p.s})`}>
<path d={wing} fill={p.a} opacity="0.92" />
<path d={wing} fill={p.b} opacity="0.55" transform="scale(0.6)" />
<line x1="0" y1="-1" x2="0" y2="4" stroke="#1f2937" strokeWidth="1" />
</g>
))}
</svg>
);
}
case 'sakura': {
const petal = 'M 0 0 C -3.4 -3 -3.4 -8 0 -9.5 C 3.4 -8 3.4 -3 0 0 Z';
return (
<svg {...SVG}>
{[
{ x: 50 - 25, y: 50 - 25, s: 0.9 }, { x: 50 + 27, y: 50 - 22, s: 0.7 },
{ x: 50 + 26, y: 50 + 24, s: 0.8 }, { x: 50 - 28, y: 50 + 18, s: 0.65 },
].map((f, i) => (
<g key={i} className="deco-float" style={{ animationDelay: `${i * 0.45}s` }} transform={`translate(${f.x} ${f.y}) scale(${f.s})`}>
{[0, 72, 144, 216, 288].map((a) => <path key={a} d={petal} fill="#fbcfe8" stroke="#f9a8d4" strokeWidth="0.5" transform={`rotate(${a})`} />)}
<circle r="1.6" fill="#fde047" />
</g>
))}
</svg>
);
}
case 'vines': {
const leaf = 'M 0 0 C -4.5 -2.5 -5.5 -7 -0.5 -8.5 C 3.5 -6.5 4 -2 0 0 Z';
return (
<svg {...SVG}>
<circle cx="50" cy="50" r={AVATAR_R + 2.5} fill="none" stroke="#15803d" strokeWidth="2.2" strokeDasharray="150 400" strokeDashoffset="20" strokeLinecap="round" />
{ring(9, AVATAR_R + 3, 200).slice(0, 7).map((p) => (
<path key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.deg + 20})`} d={leaf} fill={p.i % 2 ? '#4ade80' : '#22c55e'} stroke="#166534" strokeWidth="0.4" />
))}
</svg>
);
}
case 'bubbles':
return (
<svg {...SVG}>
{[
{ x: 50 - 27, y: 50 - 22, r: 5 }, { x: 50 + 28, y: 50 - 18, r: 3.5 },
{ x: 50 + 25, y: 50 + 24, r: 6 }, { x: 50 - 24, y: 50 + 26, r: 4 },
{ x: 50 + 33, y: 50 + 2, r: 2.6 },
].map((b, i) => (
<g key={i} className="deco-float" style={{ animationDelay: `${i * 0.5}s` }}>
<circle cx={b.x} cy={b.y} r={b.r} fill="rgba(186,230,253,0.22)" stroke="#bae6fd" strokeWidth="1" />
<circle cx={b.x - b.r * 0.35} cy={b.y - b.r * 0.35} r={b.r * 0.22} fill="#fff" opacity="0.85" />
</g>
))}
</svg>
);
// ── Worn ───────────────────────────────────────────────────────────────
case 'crown':
// Sits ON the avatar: the band overlaps the picture's top edge instead of
// floating above it, which is what made it read as detached.
return (
<svg {...SVG}>
<g className="deco-float" style={{ transformOrigin: '50% 20%' }}>
<path fill="#fbbf24" stroke="#b45309" strokeWidth="1.4" strokeLinejoin="round"
d="M 31 21 L 28 6 L 38 13.5 L 50 3 L 62 13.5 L 72 6 L 69 21 Z" />
<rect x="30" y="20" width="40" height="5.5" rx="2" fill="#f59e0b" stroke="#b45309" strokeWidth="1.2" />
<circle cx="38" cy="16" r="2" fill="#f87171" />
<circle cx="50" cy="13.5" r="2.4" fill="#60a5fa" />
<circle cx="62" cy="16" r="2" fill="#4ade80" />
</g>
</svg>
);
case 'halo':
return (
<svg {...SVG}>
<ellipse className="deco-float" cx="50" cy={50 - AVATAR_R - 4} rx="23" ry="5.5" fill="none" stroke="#fde68a" strokeWidth="3.5"
style={{ filter: 'drop-shadow(0 0 5px #fbbf24)', transformOrigin: '50% 20%' }} />
</svg>
);
case 'flower': {
const petal = 'M 0 0 C -5 -4 -5 -11 0 -13 C 5 -11 5 -4 0 0 Z';
return (
<svg {...SVG}>
<g className="deco-float" transform={`translate(${50 + 21} ${50 - AVATAR_R + 3}) scale(0.85)`}>
{[0, 60, 120, 180, 240, 300].map((a) => <path key={a} d={petal} fill="#f9a8d4" stroke="#ec4899" strokeWidth="0.6" transform={`rotate(${a})`} />)}
<circle r="3" fill="#fde047" stroke="#f59e0b" strokeWidth="0.6" />
</g>
</svg>
);
}
default: default:
return null; return null;
} }
@@ -58,7 +58,7 @@ export function ProfileCard({ user, style, displayName, bannerSrc, space, member
<div className="px-4 pb-4 relative"> <div className="px-4 pb-4 relative">
<div className="flex items-end justify-between mt-[-44px] mb-2.5"> <div className="flex items-end justify-between mt-[-44px] mb-2.5">
<DecoratedAvatar decoration={style.decoration}> <DecoratedAvatar decoration={style.decoration} size={80} ringWidth={4}>
<Avatar <Avatar
src={user.avatar} src={user.avatar}
name={displayName} name={displayName}
+19
View File
@@ -1136,6 +1136,25 @@ export const en = {
'profile.badge.veteran': 'Veteran — over a year here', 'profile.badge.veteran': 'Veteran — over a year here',
'profile.badge.og': 'OG — over six months here', 'profile.badge.og': 'OG — over six months here',
'profile.badge.stylist': 'Stylist — customised profile', 'profile.badge.stylist': 'Stylist — customised profile',
'profile.font.techno': 'Techno',
'profile.font.block': 'Block',
'profile.font.retro': 'Retro',
'profile.effect.chrome': 'Chrome',
'profile.effect.gold': 'Gold',
'profile.effect.holo': 'Holo',
'profile.effect.ice': 'Ice',
'profile.effect.shadow': '3D',
'profile.effect.outline': 'Outline',
'profile.effect.rainbow': 'Rainbow',
'profile.decoration.cd': 'Holo Disc',
'profile.decoration.chain': 'Chain',
'profile.decoration.cyber': 'Cyber',
'profile.decoration.lightning': 'Lightning',
'profile.decoration.spikes': 'Spikes',
'profile.decoration.butterfly': 'Butterflies',
'profile.decoration.sakura': 'Sakura',
'profile.decoration.bubbles': 'Bubbles',
'profile.decoration.flower': 'Flower',
'profile.widgets.collection': 'Game Collection', 'profile.widgets.collection': 'Game Collection',
'profile.widgets.favoriteGame': 'Favorite Game', 'profile.widgets.favoriteGame': 'Favorite Game',
'profile.widgets.rotation': 'Games in Rotation', 'profile.widgets.rotation': 'Games in Rotation',
+19
View File
@@ -1135,6 +1135,25 @@ export const ptBR: Partial<Dictionary> = {
'profile.badge.veteran': 'Veterano — mais de um ano de casa', 'profile.badge.veteran': 'Veterano — mais de um ano de casa',
'profile.badge.og': 'OG — mais de seis meses de casa', 'profile.badge.og': 'OG — mais de seis meses de casa',
'profile.badge.stylist': 'Estiloso — perfil personalizado', 'profile.badge.stylist': 'Estiloso — perfil personalizado',
'profile.font.techno': 'Techno',
'profile.font.block': 'Bloco',
'profile.font.retro': 'Retrô',
'profile.effect.chrome': 'Cromado',
'profile.effect.gold': 'Ouro',
'profile.effect.holo': 'Holográfico',
'profile.effect.ice': 'Gelo',
'profile.effect.shadow': '3D',
'profile.effect.outline': 'Contorno',
'profile.effect.rainbow': 'Arco-íris',
'profile.decoration.cd': 'Disco holo',
'profile.decoration.chain': 'Corrente',
'profile.decoration.cyber': 'Cyber',
'profile.decoration.lightning': 'Raios',
'profile.decoration.spikes': 'Espinhos',
'profile.decoration.butterfly': 'Borboletas',
'profile.decoration.sakura': 'Sakura',
'profile.decoration.bubbles': 'Bolhas',
'profile.decoration.flower': 'Flor',
'profile.widgets.collection': 'Coleção de jogos', 'profile.widgets.collection': 'Coleção de jogos',
'profile.widgets.favoriteGame': 'Jogo favorito', 'profile.widgets.favoriteGame': 'Jogo favorito',
'profile.widgets.rotation': 'Na rotação', 'profile.widgets.rotation': 'Na rotação',
+187 -41
View File
@@ -85,6 +85,27 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Audiowide';
src: url('/fonts/Audiowide-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Bungee';
src: url('/fonts/Bungee-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Monoton';
src: url('/fonts/Monoton-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -639,69 +660,194 @@
Profile identity — name effects, avatar decorations, profile effects, Profile identity — name effects, avatar decorations, profile effects,
nameplates. Colours arrive as CSS variables (--c1, --c2) set inline by the nameplates. Colours arrive as CSS variables (--c1, --c2) set inline by the
renderer; everything else is class-driven so the catalog in renderer; everything else is class-driven so the catalog in
shared/profile.ts is the single list of ids. shared/profile.ts stays the single list of ids.
═══════════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════════ */
/* Fonts */ /* ── Fonts ── */
.name-font-default { font-family: 'DM Sans', system-ui, sans-serif; } .name-font-default { font-family: 'DM Sans', system-ui, sans-serif; }
.name-font-display { font-family: 'Rubik Mono One', 'DM Sans', sans-serif; letter-spacing: -0.02em; } .name-font-display { font-family: 'Rubik Mono One', 'DM Sans', sans-serif; letter-spacing: -0.02em; }
.name-font-comic { font-family: 'Bangers', 'DM Sans', sans-serif; letter-spacing: 0.04em; } .name-font-comic { font-family: 'Bangers', 'DM Sans', sans-serif; letter-spacing: 0.05em; }
.name-font-script { font-family: 'Pacifico', cursive; } .name-font-script { font-family: 'Pacifico', cursive; }
.name-font-pixel { font-family: 'Press Start 2P', monospace; font-size: 0.78em; } .name-font-pixel { font-family: 'Press Start 2P', monospace; font-size: 0.72em; letter-spacing: -0.02em; }
.name-font-future { font-family: 'Orbitron', 'DM Sans', sans-serif; letter-spacing: 0.04em; } .name-font-future { font-family: 'Orbitron', 'DM Sans', sans-serif; letter-spacing: 0.05em; }
.name-font-serif { font-family: 'Playfair Display', Georgia, serif; } .name-font-serif { font-family: 'Playfair Display', Georgia, serif; }
.name-font-mono { font-family: 'Space Mono', ui-monospace, monospace; } .name-font-mono { font-family: 'Space Mono', ui-monospace, monospace; }
.name-font-round { font-family: 'Fredoka', 'DM Sans', sans-serif; } .name-font-round { font-family: 'Fredoka', 'DM Sans', sans-serif; }
.name-font-marker { font-family: 'Permanent Marker', cursive; } .name-font-marker { font-family: 'Permanent Marker', cursive; }
.name-font-techno { font-family: 'Audiowide', 'Orbitron', sans-serif; letter-spacing: 0.02em; }
.name-font-block { font-family: 'Bungee', 'DM Sans', sans-serif; letter-spacing: 0.01em; }
.name-font-retro { font-family: 'Monoton', 'DM Sans', cursive; letter-spacing: 0.04em; }
/* Effects — `.name-fx` carries the shared bits */ /* ── Effects ──
The clipped ones share `.name-fx-clip`: painting a gradient through the
glyphs needs background-clip, and repeating those four declarations per
effect is how they drift apart. */
.name-fx { display: inline-block; --c1: #efefef; --c2: #7c6cf6; } .name-fx { display: inline-block; --c1: #efefef; --c2: #7c6cf6; }
.name-fx-clip,
.name-fx-gradient, .name-fx-chrome, .name-fx-gold, .name-fx-holo,
.name-fx-gummy, .name-fx-prism, .name-fx-fire, .name-fx-ice, .name-fx-rainbow {
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.name-fx-solid { color: var(--c1); } .name-fx-solid { color: var(--c1); }
.name-fx-gradient,
.name-fx-gummy, /* Three stops with a lighter middle, so the name reads as lit rather than as a
.name-fx-prism, flat two-colour ramp. */
.name-fx-gradient {
background-image: linear-gradient(115deg,
var(--c1) 0%,
color-mix(in srgb, var(--c1) 45%, var(--c2)) 42%,
color-mix(in srgb, var(--c2) 80%, #fff) 62%,
var(--c2) 100%);
}
/* Y2K metal: hard bands, dark lip, specular highlight. Fixed palettes — the
whole point of chrome and gold is that they are those colours. */
.name-fx-chrome {
background-image: linear-gradient(178deg,
#ffffff 0%, #e2e8f0 16%, #94a3b8 33%, #475569 44%, #cbd5e1 52%,
#f8fafc 58%, #94a3b8 74%, #334155 88%, #e2e8f0 100%);
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.45)) drop-shadow(0 2px 3px rgba(0,0,0,0.65));
}
.name-fx-gold {
background-image: linear-gradient(178deg,
#fffbeb 0%, #fde68a 16%, #f59e0b 34%, #92400e 45%, #fcd34d 53%,
#fffbeb 59%, #f59e0b 76%, #78350f 90%, #fde68a 100%);
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.35)) drop-shadow(0 2px 4px rgba(0,0,0,0.6));
}
.name-fx-holo {
background-image: linear-gradient(100deg, #22d3ee, #a78bfa, #f472b6, #fbbf24, #4ade80, #22d3ee);
background-size: 400% 100%;
animation: name-slide 6s linear infinite;
filter: drop-shadow(0 0 7px rgba(167,139,250,0.55));
}
.name-fx-rainbow {
background-image: linear-gradient(90deg, #f87171, #fbbf24, #4ade80, #22d3ee, #a78bfa, #f472b6, #f87171);
background-size: 300% 100%;
animation: name-slide 3.5s linear infinite;
}
.name-fx-prism {
background-image: linear-gradient(90deg, var(--c1), var(--c2), #ffffff, var(--c1));
background-size: 300% 100%;
animation: name-slide 4s linear infinite;
}
.name-fx-fire { .name-fx-fire {
background-clip: text; -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent; background-image: linear-gradient(0deg, #7f1d1d 0%, var(--c1) 18%, var(--c2) 58%, #fffbeb 100%);
animation: name-fire 0.9s ease-in-out infinite alternate;
} }
.name-fx-gradient { background-image: linear-gradient(90deg, var(--c1), var(--c2)); } .name-fx-ice {
.name-fx-neon { color: var(--c1); text-shadow: 0 0 4px var(--c1), 0 0 12px var(--c2), 0 0 26px var(--c2); } background-image: linear-gradient(178deg, #ffffff 0%, #e0f2fe 35%, #7dd3fc 65%, #0284c7 100%);
.name-fx-toon { color: var(--c1); -webkit-text-stroke: 1px rgba(0,0,0,0.85); text-shadow: 2px 2px 0 var(--c2), 3px 3px 0 rgba(0,0,0,0.5); } filter: drop-shadow(0 0 6px #7dd3fc) drop-shadow(0 1px 1px rgba(0,0,0,0.4));
.name-fx-pop { color: var(--c1); text-shadow: 2px 2px 0 var(--c2), 4px 4px 0 rgba(0,0,0,0.45); }
.name-fx-gummy { background-image: linear-gradient(180deg, color-mix(in srgb, var(--c1) 70%, #fff), var(--c1) 45%, var(--c2)); filter: drop-shadow(0 1px 0 rgba(255,255,255,0.35)) drop-shadow(0 2px 2px rgba(0,0,0,0.5)); }
.name-fx-prism { background-image: linear-gradient(90deg, var(--c1), var(--c2), #ffffff, var(--c1)); background-size: 300% 100%; animation: name-prism 4s linear infinite; }
.name-fx-fire { background-image: linear-gradient(0deg, var(--c1) 0%, var(--c2) 55%, #fff8e1 100%); animation: name-fire 0.9s ease-in-out infinite alternate; filter: drop-shadow(0 0 6px var(--c1)); }
.name-fx-glitch { color: var(--c1); position: relative; animation: name-glitch 2.6s steps(1) infinite; }
@keyframes name-prism { to { background-position: 300% 0; } }
@keyframes name-fire { from { filter: drop-shadow(0 0 4px var(--c1)) brightness(1); } to { filter: drop-shadow(0 0 10px var(--c2)) brightness(1.15); } }
@keyframes name-glitch {
0%, 84%, 100% { text-shadow: none; transform: none; }
86% { text-shadow: -2px 0 #22d3ee, 2px 0 var(--c2); transform: translateX(1px); }
88% { text-shadow: 2px 0 #22d3ee, -2px 0 var(--c2); transform: translateX(-1px) skewX(4deg); }
90% { text-shadow: -1px 0 #22d3ee, 1px 0 var(--c2); transform: none; }
92% { text-shadow: none; }
95% { text-shadow: 3px 0 var(--c2), -3px 0 #22d3ee; transform: translateY(-1px); }
97% { text-shadow: none; transform: none; }
} }
/* Avatar decorations — an absolutely positioned layer over the avatar */ /* Glossy sweet: light top half, saturated bottom, wet highlight. */
.avatar-deco { position: absolute; inset: -14%; pointer-events: none; } .name-fx-gummy {
.avatar-deco svg { width: 100%; height: 100%; overflow: visible; } background-image: linear-gradient(180deg,
color-mix(in srgb, var(--c1) 55%, #fff) 0%,
var(--c1) 44%,
color-mix(in srgb, var(--c2) 85%, #000) 100%);
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.55)) drop-shadow(0 3px 3px rgba(0,0,0,0.45));
}
.name-fx-neon {
color: color-mix(in srgb, var(--c1) 45%, #fff);
text-shadow:
0 0 2px #fff,
0 0 6px var(--c1),
0 0 14px var(--c2),
0 0 30px var(--c2),
0 0 52px color-mix(in srgb, var(--c2) 60%, transparent);
animation: name-neon-buzz 4s ease-in-out infinite;
}
.name-fx-toon {
color: var(--c1);
-webkit-text-stroke: 2px rgba(0,0,0,0.9);
paint-order: stroke fill;
text-shadow: 3px 3px 0 rgba(0,0,0,0.9), 3px 3px 0 var(--c2);
}
.name-fx-pop {
color: var(--c1);
text-shadow: 2px 2px 0 var(--c2), 4px 4px 0 color-mix(in srgb, var(--c2) 55%, #000), 6px 6px 8px rgba(0,0,0,0.45);
}
/* True extrude: a stack of 1px steps reads as depth where two offsets read as a
double image. */
.name-fx-shadow {
color: var(--c1);
text-shadow:
1px 1px 0 var(--c2), 2px 2px 0 var(--c2), 3px 3px 0 var(--c2),
4px 4px 0 var(--c2), 5px 5px 0 color-mix(in srgb, var(--c2) 60%, #000),
6px 7px 6px rgba(0,0,0,0.5);
}
.name-fx-outline {
color: transparent;
-webkit-text-stroke: 1.6px var(--c1);
paint-order: stroke fill;
text-shadow: 0 0 14px color-mix(in srgb, var(--c2) 55%, transparent);
}
/* RGB split from the real text, duplicated through ::before/::after — the
renderer puts the name in data-text for exactly this. */
.name-fx-glitch { color: var(--c1); position: relative; }
.name-fx-glitch::before,
.name-fx-glitch::after {
content: attr(data-text);
position: absolute;
left: 0; top: 0;
width: 100%;
pointer-events: none;
}
.name-fx-glitch::before { color: #22d3ee; animation: name-glitch-a 3.1s steps(1) infinite; }
.name-fx-glitch::after { color: var(--c2); animation: name-glitch-b 2.7s steps(1) infinite; }
@keyframes name-slide { to { background-position: 300% 0; } }
@keyframes name-fire {
from { filter: drop-shadow(0 0 4px #f97316) brightness(1); }
to { filter: drop-shadow(0 0 12px #fbbf24) brightness(1.18); }
}
@keyframes name-neon-buzz {
0%, 92%, 100% { opacity: 1; }
94% { opacity: 0.72; }
96% { opacity: 1; }
}
@keyframes name-glitch-a {
0%, 88%, 100% { clip-path: inset(100% 0 0 0); transform: none; }
89% { clip-path: inset(12% 0 62% 0); transform: translate(-2px, -1px); }
91% { clip-path: inset(58% 0 18% 0); transform: translate(2px, 1px); }
93% { clip-path: inset(32% 0 44% 0); transform: translate(-1px, 0); }
}
@keyframes name-glitch-b {
0%, 90%, 100% { clip-path: inset(100% 0 0 0); transform: none; }
91% { clip-path: inset(66% 0 12% 0); transform: translate(2px, 1px); }
94% { clip-path: inset(20% 0 56% 0); transform: translate(-2px, -1px); }
97% { clip-path: inset(44% 0 34% 0); transform: translate(1px, 0); }
}
/* ── Avatar decorations ──
Positioning lives in DecoratedAvatar (it needs the avatar's px geometry);
only the motion is here. */
.deco-spin { animation: deco-spin 9s linear infinite; } .deco-spin { animation: deco-spin 9s linear infinite; }
.deco-spin-slow { animation: deco-spin 22s linear infinite; } .deco-spin-slow { animation: deco-spin 22s linear infinite; }
.deco-pulse { animation: deco-pulse 2.2s ease-in-out infinite; } .deco-pulse { animation: deco-pulse 2.4s ease-in-out infinite; }
.deco-flicker { animation: deco-flicker 0.35s ease-in-out infinite alternate; } .deco-flicker { animation: deco-flicker 0.32s ease-in-out infinite alternate; }
.deco-float { animation: deco-float 3s ease-in-out infinite; } .deco-float { animation: deco-float 3s ease-in-out infinite; }
.deco-twinkle { animation: deco-twinkle 2.2s ease-in-out infinite; }
@keyframes deco-spin { to { transform: rotate(360deg); } } @keyframes deco-spin { to { transform: rotate(360deg); } }
@keyframes deco-pulse { 0%,100% { opacity: 0.75; transform: scale(1); } 50% { opacity: 1; transform: scale(1.04); } } @keyframes deco-pulse { 0%, 100% { opacity: 0.72; } 50% { opacity: 1; } }
@keyframes deco-flicker { from { transform: scaleY(1) translateY(0); opacity: 0.9; } to { transform: scaleY(1.08) translateY(-2%); opacity: 1; } } @keyframes deco-flicker { from { transform: scale(1); opacity: 0.88; } to { transform: scale(1.035); opacity: 1; } }
@keyframes deco-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6%); } } @keyframes deco-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4%); } }
@keyframes deco-twinkle { 0%, 100% { opacity: 0.25; transform: scale(0.6); } 50% { opacity: 1; transform: scale(1); } }
/* Profile effects — particle layers over the banner */ /* ── Profile effects — particle layers over the banner ── */
.profile-fx { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } .profile-fx { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.profile-fx i { position: absolute; display: block; will-change: transform, opacity; } .profile-fx i { position: absolute; display: block; will-change: transform, opacity; }
.fx-sparkles i { width: 6px; height: 6px; background: #fff; border-radius: 50%; box-shadow: 0 0 6px 2px rgba(255,255,255,0.8); animation: fx-twinkle var(--d, 2s) ease-in-out var(--t, 0s) infinite; } .fx-sparkles i { width: 6px; height: 6px; background: #fff; border-radius: 50%; box-shadow: 0 0 6px 2px rgba(255,255,255,0.8); animation: fx-twinkle var(--d, 2s) ease-in-out var(--t, 0s) infinite; }
.fx-rain i { width: 1.5px; height: 18px; background: linear-gradient(180deg, transparent, rgba(180,200,255,0.8)); animation: fx-fall var(--d, 1s) linear var(--t, 0s) infinite; } .fx-rain i { width: 1.5px; height: 18px; background: linear-gradient(180deg, transparent, rgba(180,200,255,0.85)); animation: fx-fall var(--d, 1s) linear var(--t, 0s) infinite; }
.fx-snow i { width: 5px; height: 5px; background: #fff; border-radius: 50%; opacity: 0.9; animation: fx-drift var(--d, 5s) linear var(--t, 0s) infinite; } .fx-snow i { width: 5px; height: 5px; background: #fff; border-radius: 50%; opacity: 0.9; animation: fx-drift var(--d, 5s) linear var(--t, 0s) infinite; }
.fx-matrix i { width: 10px; font: 700 10px 'Space Mono', monospace; color: #4ade80; text-shadow: 0 0 6px #22c55e; line-height: 1; animation: fx-fall var(--d, 3s) linear var(--t, 0s) infinite; } .fx-matrix i { width: 10px; font: 700 10px 'Space Mono', monospace; color: #4ade80; text-shadow: 0 0 6px #22c55e; line-height: 1; animation: fx-fall var(--d, 3s) linear var(--t, 0s) infinite; }
.fx-confetti i { width: 7px; height: 11px; background: var(--k, #f472b6); border-radius: 1px; animation: fx-confetti var(--d, 3s) ease-in var(--t, 0s) infinite; } .fx-confetti i { width: 7px; height: 11px; background: var(--k, #f472b6); border-radius: 1px; animation: fx-confetti var(--d, 3s) ease-in var(--t, 0s) infinite; }
@@ -714,14 +860,14 @@
animation: fx-aurora 9s ease-in-out infinite alternate; animation: fx-aurora 9s ease-in-out infinite alternate;
} }
.fx-aurora::after { animation-duration: 13s; animation-direction: alternate-reverse; opacity: 0.35; } .fx-aurora::after { animation-duration: 13s; animation-direction: alternate-reverse; opacity: 0.35; }
@keyframes fx-twinkle { 0%,100% { opacity: 0; transform: scale(0.4); } 50% { opacity: 1; transform: scale(1); } } @keyframes fx-twinkle { 0%, 100% { opacity: 0; transform: scale(0.4); } 50% { opacity: 1; transform: scale(1); } }
@keyframes fx-fall { from { transform: translateY(-30px); opacity: 0; } 10% { opacity: 1; } to { transform: translateY(140px); opacity: 0; } } @keyframes fx-fall { from { transform: translateY(-30px); opacity: 0; } 10% { opacity: 1; } to { transform: translateY(140px); opacity: 0; } }
@keyframes fx-drift { from { transform: translate(0, -10px); } to { transform: translate(14px, 130px); } } @keyframes fx-drift { from { transform: translate(0, -10px); } to { transform: translate(14px, 130px); } }
@keyframes fx-confetti { from { transform: translateY(-20px) rotate(0); opacity: 1; } to { transform: translateY(130px) rotate(540deg); opacity: 0.2; } } @keyframes fx-confetti { from { transform: translateY(-20px) rotate(0); opacity: 1; } to { transform: translateY(130px) rotate(540deg); opacity: 0.2; } }
@keyframes fx-rise { from { transform: translateY(20px) scale(0.6); opacity: 0; } 20% { opacity: 1; } to { transform: translateY(-120px) scale(1.1); opacity: 0; } } @keyframes fx-rise { from { transform: translateY(20px) scale(0.6); opacity: 0; } 20% { opacity: 1; } to { transform: translateY(-120px) scale(1.1); opacity: 0; } }
@keyframes fx-aurora { from { background-position: 0% 50%; transform: translateX(-6%) rotate(-4deg); } to { background-position: 100% 50%; transform: translateX(6%) rotate(4deg); } } @keyframes fx-aurora { from { background-position: 0% 50%; transform: translateX(-6%) rotate(-4deg); } to { background-position: 100% 50%; transform: translateX(6%) rotate(4deg); } }
/* Nameplates — member-row background, fading out toward the right */ /* ── Nameplates — member-row background, fading out toward the right ── */
.nameplate { --np1: #7c6cf6; --np2: #f472b6; background: linear-gradient(90deg, color-mix(in srgb, var(--np1) 55%, transparent), color-mix(in srgb, var(--np2) 30%, transparent) 60%, transparent); } .nameplate { --np1: #7c6cf6; --np2: #f472b6; background: linear-gradient(90deg, color-mix(in srgb, var(--np1) 55%, transparent), color-mix(in srgb, var(--np2) 30%, transparent) 60%, transparent); }
.nameplate-sunset { --np1: #f97316; --np2: #ec4899; } .nameplate-sunset { --np1: #f97316; --np2: #ec4899; }
.nameplate-ocean { --np1: #0ea5e9; --np2: #6366f1; } .nameplate-ocean { --np1: #0ea5e9; --np2: #6366f1; }
+18 -2
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS } from '@backspace/shared/src/profile'; import {
normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS,
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES,
} from '@backspace/shared/src/profile';
import { computeBadges, detectLinkIcon } from './profileStyle'; import { computeBadges, detectLinkIcon } from './profileStyle';
import type { User } from '@backspace/shared'; import type { User } from '@backspace/shared';
@@ -11,7 +14,9 @@ describe('normalizeProfileStyle', () => {
}); });
it('falls back on unknown catalog ids instead of throwing', () => { it('falls back on unknown catalog ids instead of throwing', () => {
const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'rainbow' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' }); // Ids that are not in any catalog — deliberately absurd, so growing a
// catalog never quietly turns this case into a passing no-op.
const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'interpretive-dance' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' });
expect(s.name.font).toBe('default'); expect(s.name.font).toBe('default');
expect(s.name.effect).toBe('solid'); expect(s.name.effect).toBe('solid');
expect(s.decoration).toBe('none'); expect(s.decoration).toBe('none');
@@ -59,6 +64,17 @@ describe('normalizeProfileStyle', () => {
expect(s.widgets.rotation[0]?.name).toBe('Sneaky'); expect(s.widgets.rotation[0]?.name).toBe('Sneaky');
}); });
it('accepts every id the catalogs declare', () => {
// Guards the pairing between the catalogs and the normaliser: adding an id
// to one without the other would show up here rather than as a silent
// fallback in the UI.
for (const font of NAME_FONTS) expect(normalizeProfileStyle({ name: { font } }).name.font).toBe(font);
for (const effect of NAME_EFFECTS) expect(normalizeProfileStyle({ name: { effect } }).name.effect).toBe(effect);
for (const decoration of AVATAR_DECORATIONS) expect(normalizeProfileStyle({ decoration }).decoration).toBe(decoration);
for (const effect of PROFILE_EFFECTS) expect(normalizeProfileStyle({ effect }).effect).toBe(effect);
for (const nameplate of NAMEPLATES) expect(normalizeProfileStyle({ nameplate }).nameplate).toBe(nameplate);
});
it('is idempotent', () => { it('is idempotent', () => {
const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' }); const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' });
expect(normalizeProfileStyle(once)).toEqual(once); expect(normalizeProfileStyle(once)).toEqual(once);