feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
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 things, from the screenshots.

Banner repositioning, which was a real hole. A banner was always cropped dead
centre, and a GIF could not go through the still-image cropper at all — so
there was no way to frame one. The crop is now two percentages and a zoom
stored in the profile (`banner: {x, y, zoom}`), and the banner renders as an
<img> with object-position plus a scale rather than a CSS background: those two
compose into pan-and-zoom, where `background-size: cover` cannot be zoomed
without knowing the file's dimensions. The editor drags the live element, so a
GIF keeps animating while you frame it, and nothing is ever re-encoded.

The editor now has the reference's shape: a rail of cards on the left, each
showing what it currently holds, and the live profile beside it. A card opens a
dialog with the options on one side and a preview on the other. That is the
part both earlier attempts got wrong — the first put a 360px preview inside a
640px column, the second made you leave the preview to open a section. Here
they are on screen together at full size.

The dialog edits a copy: Cancel discards it, Apply commits to the draft, and
the draft is only persisted by Save. So backing out of a dialog cannot cost
unrelated work, and neither can the per-section reset in its header.

"Surprise me" randomises the look and nothing else. Fonts, effects, decoration,
theme and nameplate change; pronouns, tag, links, board and the banner crop are
the user's own words and framing, and rolling dice over those would be
vandalism. A test asserts exactly that over 40 rolls, and that every roll lands
on ids the normaliser accepts.

Five more nameplates in the y2k direction — holo, chrome, vapor, matrix, ember
— since the existing ten were all plain gradients.

Verified: shared build, web build, server typecheck, 523 web tests (5 new:
banner clamping and round-trip, randomiser bounds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:02:29 -03:00
co-authored by Claude Opus 5
parent e6eba7a2b7
commit 033ba63762
13 changed files with 657 additions and 190 deletions
@@ -0,0 +1,66 @@
import React from 'react';
import type { User } from '@backspace/shared';
import type { ProfileStyle } from '@backspace/shared/src/profile';
import { Avatar } from '../ui/Avatar';
import { ProfileCard } from './ProfileCard';
import { ProfileTagChip } from './ProfileTagChip';
import { nameStyleProps, nameplateProps } from '../../utils/profileStyle';
import { useT } from '../../i18n';
interface ProfilePreviewStackProps {
user: User;
style: ProfileStyle;
displayName: string;
bannerSrc: string | null;
/** Card only — used inside the edit dialogs, where space is tight. */
compact?: boolean;
}
/**
* The three places a profile is actually seen: the card, a chat line, and the
* member-list row. Shared by the editor's main preview and by every edit
* dialog, so "what you are changing" is always shown in context rather than as
* an isolated swatch.
*/
export function ProfilePreviewStack({ user, style, displayName, bannerSrc, compact = false }: ProfilePreviewStackProps) {
const t = useT();
const nameP = nameStyleProps(style.name);
const plate = nameplateProps(style.nameplate, style.nameplateColors);
const preview: User = { ...user, profileStyle: style };
return (
<div className="space-y-4">
<div className="rounded-[12px] overflow-hidden glass-modal select-none">
<ProfileCard user={preview} style={style} displayName={displayName} bannerSrc={bannerSrc} />
</div>
{!compact && (
<>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.inChat')}</div>
<div className="flex items-start gap-3 rounded-xl bg-white/[0.03] p-3">
<Avatar src={user.avatar} name={displayName} size={36} userId={user.homeUserId ?? user.id} user={user} />
<div className="min-w-0">
<div className="flex items-center gap-2 leading-tight">
<span className={`${nameP.className} text-[15px] font-semibold`} style={nameP.style} data-text={displayName}>{displayName}</span>
{style.tag && <ProfileTagChip tag={style.tag} className="scale-90 origin-left" />}
<span className="text-[11px] text-txt-tertiary">21:19</span>
</div>
<div className="text-[14px] text-txt-secondary">{t('profile.editor.inChat.sample')}</div>
</div>
</div>
</div>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.inMembers')}</div>
<div className={`flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] ${plate?.className ?? 'bg-white/[0.03]'}`} style={plate?.style}>
<Avatar src={user.avatar} name={displayName} size={32} status="online" userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[13.5px] font-medium text-txt-primary truncate">{displayName}</span>
{style.tag && <ProfileTagChip tag={style.tag} className="scale-90 origin-left" />}
</div>
</div>
</>
)}
</div>
);
}