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
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user