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
+36 -1
View File
@@ -37,7 +37,8 @@ export const PROFILE_EFFECTS = [
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
export const NAMEPLATES = [
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight', 'custom',
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight',
'holo', 'chrome', 'vapor', 'matrix', 'ember', 'custom',
] as const;
export type Nameplate = (typeof NAMEPLATES)[number];
@@ -48,6 +49,24 @@ export interface NameStyle {
colors: [string, string];
}
/**
* Where the banner image sits inside its frame.
*
* A banner is cropped by the frame, and until now the crop was always dead
* centre — fine for a photo shot for it, wrong for anything else, and there was
* no way to fix it for a GIF, which cannot go through the still-image cropper.
* `x`/`y` are CSS object-position percentages; `zoom` scales the image inside
* the frame, so together they are a pan-and-zoom crop that costs nothing at
* render time and keeps the original file untouched.
*/
export interface BannerPosition {
/** 0–100. 50 is centred. */
x: number;
y: number;
/** 100–300 (%). 100 fills the frame. */
zoom: number;
}
export interface ProfileTheme {
/** Two hex colours behind the card and the banner fallback. */
colors: [string, string];
@@ -91,6 +110,7 @@ export interface ProfileWidgets {
export interface ProfileStyle {
name: NameStyle;
theme: ProfileTheme | null;
banner: BannerPosition;
decoration: AvatarDecoration;
effect: ProfileEffect;
nameplate: Nameplate;
@@ -119,9 +139,12 @@ export const PROFILE_LIMITS = {
quoteAuthor: 40,
} as const;
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
theme: null,
banner: DEFAULT_BANNER_POSITION,
decoration: 'none',
effect: 'none',
nameplate: 'none',
@@ -157,6 +180,12 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
function hex(value: unknown, fallback: string): string {
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
}
/** Clamps to a range, falling back when the value is not a finite number. */
function num(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
}
function str(value: unknown, max: number): string {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
@@ -212,6 +241,7 @@ export function normalizeProfileStyle(input: unknown): ProfileStyle {
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 banner = (v.banner && typeof v.banner === 'object' ? v.banner : {}) as Record<string, unknown>;
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>;
@@ -235,6 +265,11 @@ export function normalizeProfileStyle(input: unknown): ProfileStyle {
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,
banner: {
x: num(banner.x, 0, 100, d.banner.x),
y: num(banner.y, 0, 100, d.banner.y),
zoom: num(banner.zoom, 100, 300, d.banner.zoom),
},
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),