Files
backspace/packages/web/src/components/profile/ProfileBanner.tsx
T
devsyncwrldandClaude Opus 5 033ba63762
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
feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
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>
2026-09-03 10:02:29 -03:00

38 lines
1.2 KiB
TypeScript

import React from 'react';
import type { ProfileStyle } from '@backspace/shared/src/profile';
import { bannerImageStyle, themeGradient } from '../../utils/profileStyle';
import { ProfileEffectLayer } from './ProfileEffectLayer';
interface ProfileBannerProps {
src: string | null;
style: ProfileStyle;
/** Gradient used when there is no image and no theme. */
fallback: string;
className?: string;
effectDensity?: number;
children?: React.ReactNode;
}
/**
* The banner strip: image (framed by the user's crop), theme gradient, or the
* identity gradient — in that order — with the profile effect over it.
*/
export function ProfileBanner({ src, style, fallback, className = '', effectDensity = 1, children }: ProfileBannerProps) {
const theme = themeGradient(style);
return (
<div className={`relative overflow-hidden ${className}`} style={src ? undefined : { background: theme ?? fallback }}>
{src && (
<img
src={src}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-cover select-none"
style={bannerImageStyle(style.banner)}
/>
)}
<ProfileEffectLayer effect={style.effect} density={effectDensity} />
{children}
</div>
);
}