feat(web): add persistent beam avatars
Staging Backend Quality Gate / validate (push) Successful in 13m34s
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse
2026-08-07 12:07:57 +08:00
parent e7482c49c6
commit 49f6edd7fb
11 changed files with 420 additions and 2 deletions
@@ -0,0 +1,30 @@
begin;
do $migration$
begin
if to_regclass('identity.users') is not null
and to_regclass('public.profiles') is not null then
alter table public.profiles
add column if not exists avatar_seed uuid not null default gen_random_uuid(),
add column if not exists avatar_palette smallint not null
default (floor(random() * 8)::smallint),
add column if not exists avatar_renderer_version smallint not null default 1;
alter table public.profiles
drop constraint if exists profiles_avatar_palette_check,
add constraint profiles_avatar_palette_check
check (avatar_palette between 0 and 7),
drop constraint if exists profiles_avatar_renderer_version_check,
add constraint profiles_avatar_renderer_version_check
check (avatar_renderer_version = 1);
grant update (
avatar_seed,
avatar_palette,
avatar_renderer_version
) on table public.profiles to authenticated;
end if;
end
$migration$;
commit;
+11
View File
@@ -15,6 +15,7 @@
"@supabase/supabase-js": "^2.110.5",
"@tailwindcss/postcss": "^4.3.2",
"better-auth": "1.6.23",
"boring-avatars": "2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
@@ -4115,6 +4116,16 @@
"node": ">=6.0.0"
}
},
"node_modules/boring-avatars": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/boring-avatars/-/boring-avatars-2.0.4.tgz",
"integrity": "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/better-auth": {
"version": "1.6.23",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@tailwindcss/postcss": "^4.3.2",
"antd": "^5.29.3",
"better-auth": "1.6.23",
"boring-avatars": "2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
beamAvatarFromProfile,
beamAvatarPatchSchema,
createBeamAvatarProfilePatch,
} from "@/lib/beam-avatar";
export const runtime = "nodejs";
function selfHostedOnly() {
return process.env.AUTH_PROVIDER?.trim() === "self-hosted";
}
export async function PATCH(request: Request) {
if (!selfHostedOnly()) {
return NextResponse.json({ error: "头像编辑仅在 staging 开放" }, { status: 404 });
}
try {
const client = await createServerSupabaseClient();
const { data: { user }, error: authError } = await client.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const parsed = beamAvatarPatchSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json({
error: "头像设置格式不正确",
details: parsed.error.flatten(),
}, { status: 400 });
}
const values = {
...createBeamAvatarProfilePatch(parsed.data),
updated_at: new Date().toISOString(),
};
const { data: profile, error } = await client
.from("profiles")
.update(values)
.eq("id", user.id)
.select("avatar_seed,avatar_palette,avatar_renderer_version")
.maybeSingle();
if (error || !profile) {
return NextResponse.json({ error: "暂时无法保存头像" }, { status: 500 });
}
return NextResponse.json({ avatar: beamAvatarFromProfile(profile) });
} catch {
return NextResponse.json({ error: "头像服务暂时不可用" }, { status: 500 });
}
}
+19
View File
@@ -8,6 +8,7 @@ import {
applyAccountProfileConcurrencyGuards,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
@@ -110,6 +111,23 @@ export async function GET() {
if (profileError || !profile) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
let avatar = null;
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const { data: avatarProfile, error: avatarError } = await supabase
.from("profiles")
.select("avatar_seed,avatar_palette,avatar_renderer_version")
.eq("id", userId)
.single();
if (avatarError || !avatarProfile) {
return NextResponse.json({ error: "暂时无法读取头像" }, { status: 500 });
}
avatar = optionalBeamAvatarFromProfile(avatarProfile);
if (!avatar) {
return NextResponse.json({ error: "头像设置无效" }, { status: 500 });
}
}
const rectificationCase = resolveAccountRectificationV4Case(
Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
) ?? resolveAccountRectificationCase(
@@ -134,6 +152,7 @@ export async function GET() {
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
avatar,
credits: profile.credits,
isAdmin,
adminUrl,
+20
View File
@@ -345,6 +345,9 @@ button:disabled { cursor: default; opacity: .45; }
.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); }
.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); }
.user-avatar > svg { width: 100%; height: 100%; display: block; }
.profile-avatar { width: 32px; height: 32px; }
.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; }
.profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; }
[data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; }
@@ -709,6 +712,7 @@ button:disabled { cursor: default; opacity: .45; }
.account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); }
.account-menu-identity { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); margin-bottom: var(--space-1); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.account-menu-avatar { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; }
.user-avatar.account-menu-avatar { overflow: hidden; }
.account-menu-identity > span:last-child { min-width: 0; }
.account-menu-identity b, .account-menu-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-menu-identity b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
@@ -732,6 +736,19 @@ button:disabled { cursor: default; opacity: .45; }
.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
.profile-modal .birth-section { padding-top: var(--space-5); }
.avatar-section { padding-top: 0; }
.avatar-editor { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); }
.avatar-editor-controls { min-width: 0; display: grid; gap: var(--space-4); }
.avatar-palette-list { display: grid; grid-template-columns: repeat(4, minmax(48px, 1fr)); gap: var(--space-2); }
.avatar-palette { min-width: 0; height: 34px; overflow: hidden; display: flex; padding: 3px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out; }
.avatar-palette > span { min-width: 0; flex: 1; }
.avatar-palette > span:first-child { border-radius: calc(var(--radius-md) - 4px) 0 0 calc(var(--radius-md) - 4px); }
.avatar-palette > span:last-child { border-radius: 0 calc(var(--radius-md) - 4px) calc(var(--radius-md) - 4px) 0; }
.avatar-palette[aria-checked="true"] { border-color: var(--color-action); box-shadow: 0 0 0 2px var(--color-action-soft); }
.avatar-palette:disabled { cursor: wait; opacity: .65; }
.avatar-palette:not(:disabled):active { transform: scale(.96); }
.avatar-randomize { justify-self: start; }
.avatar-notice { margin-top: var(--space-4); }
.redeem-modal { width: min(100%, 420px); }
.logout-modal { width: min(100%, 400px); }
.account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
@@ -866,6 +883,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 72px !important; height: 72px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.auth-page { padding: 0; }
.auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); }
+76
View File
@@ -8,6 +8,7 @@ import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { UserAvatar } from "@/components/user-avatar";
import {
BirthTimeAssessmentOverlay,
type BirthTimeAssessmentPhase,
@@ -28,6 +29,12 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
import {
beamAvatarPalettes,
beamAvatarSchema,
type BeamAvatar,
type BeamAvatarPatch,
} from "@/lib/beam-avatar";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
@@ -179,6 +186,7 @@ type BirthPlace = {
};
type Account = {
user: { id: string; email: string | null };
avatar: BeamAvatar | null;
credits: number;
isAdmin: boolean;
adminUrl: string | null;
@@ -933,6 +941,8 @@ export default function Home() {
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
const [redeemCode, setRedeemCode] = useState("");
@@ -1257,6 +1267,7 @@ export default function Home() {
};
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
avatar: null,
credits: 8,
isAdmin: false,
adminUrl: null,
@@ -1692,6 +1703,7 @@ export default function Home() {
case "profile":
setProfileDraft(profile);
setProfileNotice("");
setAvatarNotice("");
break;
case "redeem":
setRedeemError("");
@@ -1718,6 +1730,30 @@ export default function Home() {
window.requestAnimationFrame(() => returnTarget?.focus());
}
async function persistAvatar(patch: BeamAvatarPatch) {
if (!account?.avatar || avatarSaving) return;
setAvatarSaving(true);
setAvatarNotice("");
setAccountError("");
try {
const response = await fetch("/api/account/avatar", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。");
const avatar = beamAvatarSchema.parse(payload?.avatar);
setAccount((current) => current ? { ...current, avatar } : current);
setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败"));
} finally {
setAvatarSaving(false);
}
}
async function persistProfile(nextProfile: Profile) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
@@ -2769,6 +2805,7 @@ export default function Home() {
credits: account.credits,
isAdmin: account.isAdmin,
adminUrl: account.adminUrl,
avatar: account.avatar,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
|| "你",
@@ -3153,6 +3190,45 @@ export default function Home() {
{activeAccountDialog === "profile" && (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title"></b>
<small>Beam </small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b></b><small></small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
+9 -2
View File
@@ -30,12 +30,15 @@ import {
type SidebarSession,
type SidebarSessionControls,
} from "@/components/sidebar-session-row";
import { UserAvatar } from "@/components/user-avatar";
import type { BeamAvatar } from "@/lib/beam-avatar";
export type SidebarAccount = {
name: string;
email: string;
credits: number;
initial: string;
avatar: BeamAvatar | null;
};
export type AppSidebarProps = {
@@ -183,7 +186,9 @@ export function AppSidebar({
ref={accountTriggerRef}
type="button"
>
<span className="profile-initial" aria-hidden="true">{account.initial}</span>
{account.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
</Menu.Trigger>
@@ -196,7 +201,9 @@ export function AppSidebar({
>
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
<div className="account-menu-identity">
<span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import Avatar from "boring-avatars";
import { beamAvatarPalettes, type BeamAvatar } from "@/lib/beam-avatar";
export type UserAvatarProps = {
avatar: BeamAvatar;
size: number;
className?: string;
label?: string;
};
export function UserAvatar({ avatar, size, className, label }: UserAvatarProps) {
const palette = beamAvatarPalettes[avatar.palette] ?? beamAvatarPalettes[0];
return (
<span
className={className ? `user-avatar ${className}` : "user-avatar"}
style={{ width: size, height: size }}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<Avatar
name={avatar.seed}
variant="beam"
colors={[...palette.colors]}
size={size}
/>
</span>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
export const BEAM_AVATAR_RENDERER_VERSION = 1 as const;
export const beamAvatarPalettes = [
{ name: "星砂", colors: ["#17324D", "#2D6073", "#D5A45D", "#F2E6CF", "#B9684A"] },
{ name: "靛夜", colors: ["#202842", "#495B8C", "#93A3D1", "#E4D9C7", "#C58D68"] },
{ name: "莲火", colors: ["#553640", "#A35D63", "#D99B7A", "#F2D8BA", "#5E7161"] },
{ name: "松雾", colors: ["#1F3D3A", "#4D746C", "#A7B59A", "#E8DEC8", "#C77B56"] },
{ name: "晨曦", colors: ["#34405F", "#7484A6", "#E2AF72", "#F4E5C9", "#B75D54"] },
{ name: "紫藤", colors: ["#3E3555", "#77638F", "#B9A3CA", "#E9DCCB", "#C47B6C"] },
{ name: "海盐", colors: ["#1D4554", "#4F8090", "#A8C0BD", "#EFE2C8", "#D18A5D"] },
{ name: "桂影", colors: ["#443B2D", "#7B6848", "#C6A25E", "#EFE0BE", "#8C5B4C"] },
] as const;
export const beamAvatarPaletteSchema = z.number().int().min(0).max(beamAvatarPalettes.length - 1);
export const beamAvatarSchema = z.object({
seed: z.string().uuid(),
palette: beamAvatarPaletteSchema,
rendererVersion: z.literal(BEAM_AVATAR_RENDERER_VERSION),
}).strict();
export const beamAvatarPatchSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("set_palette"),
palette: beamAvatarPaletteSchema,
}).strict(),
z.object({
action: z.literal("randomize"),
palette: beamAvatarPaletteSchema.optional(),
}).strict(),
]);
export type BeamAvatar = z.infer<typeof beamAvatarSchema>;
export type BeamAvatarPatch = z.infer<typeof beamAvatarPatchSchema>;
export function createBeamAvatarProfilePatch(
patch: BeamAvatarPatch,
createSeed: () => string = () => globalThis.crypto.randomUUID(),
) {
return {
...(patch.palette !== undefined ? { avatar_palette: patch.palette } : {}),
avatar_renderer_version: BEAM_AVATAR_RENDERER_VERSION,
...(patch.action === "randomize" ? { avatar_seed: createSeed() } : {}),
};
}
export function beamAvatarFromProfile(profile: Record<string, unknown>): BeamAvatar {
return beamAvatarSchema.parse({
seed: profile.avatar_seed,
palette: profile.avatar_palette,
rendererVersion: profile.avatar_renderer_version,
});
}
export function optionalBeamAvatarFromProfile(
profile: Record<string, unknown>,
): BeamAvatar | null {
const parsed = beamAvatarSchema.safeParse({
seed: profile.avatar_seed,
palette: profile.avatar_palette,
rendererVersion: profile.avatar_renderer_version,
});
return parsed.success ? parsed.data : null;
}
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { UserAvatar } from "../src/components/user-avatar.tsx";
import {
BEAM_AVATAR_RENDERER_VERSION,
beamAvatarPalettes,
beamAvatarPatchSchema,
beamAvatarSchema,
createBeamAvatarProfilePatch,
} from "../src/lib/beam-avatar.ts";
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const accountRoute = readProjectFile("src/app/api/account/route.ts");
const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts");
const avatarComponent = readProjectFile("src/components/user-avatar.tsx");
const sidebar = readProjectFile("src/components/app-sidebar.tsx");
const page = readProjectFile("src/app/page.tsx");
const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql");
const seed = "11111111-1111-4111-8111-111111111111";
test("defines eight reviewed Beam palettes and versioned persisted parameters", () => {
assert.equal(beamAvatarPalettes.length, 8);
assert.equal(new Set(beamAvatarPalettes.map((palette) => palette.name)).size, 8);
assert.equal(beamAvatarPalettes.every((palette) => palette.colors.length === 5), true);
assert.deepEqual(beamAvatarSchema.parse({
seed,
palette: 7,
rendererVersion: BEAM_AVATAR_RENDERER_VERSION,
}), { seed, palette: 7, rendererVersion: 1 });
assert.equal(beamAvatarSchema.safeParse({ seed, palette: 8, rendererVersion: 1 }).success, false);
assert.equal(beamAvatarSchema.safeParse({ seed: "email@example.test", palette: 0, rendererVersion: 1 }).success, false);
});
test("renders identical Beam SVG for identical persisted parameters", () => {
const avatar = { seed, palette: 0, rendererVersion: 1 } as const;
const first = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
const second = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
assert.equal(first, second);
assert.match(first, /<svg/);
assert.match(first, /width="40"/);
assert.match(first, /height="40"/);
});
test("accepts only bounded avatar actions and creates new seeds on the server", () => {
assert.equal(beamAvatarPatchSchema.safeParse({ action: "set_palette", palette: 3 }).success, true);
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize" }).success, true);
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize", seed }).success, false);
assert.equal(beamAvatarPatchSchema.safeParse({ action: "upload" }).success, false);
assert.deepEqual(
createBeamAvatarProfilePatch({ action: "set_palette", palette: 3 }, () => seed),
{ avatar_palette: 3, avatar_renderer_version: 1 },
);
assert.deepEqual(
createBeamAvatarProfilePatch({ action: "randomize" }, () => seed),
{ avatar_renderer_version: 1, avatar_seed: seed },
);
});
test("persists random defaults and constraints only for self-hosted profiles", () => {
assert.match(migration, /to_regclass\('identity\.users'\) is not null/);
assert.match(migration, /avatar_seed uuid not null default gen_random_uuid\(\)/);
assert.match(migration, /avatar_palette smallint not null[\s\S]*floor\(random\(\) \* 8\)/);
assert.match(migration, /check \(avatar_palette between 0 and 7\)/);
assert.match(migration, /check \(avatar_renderer_version = 1\)/);
assert.match(migration, /grant update \([\s\S]*avatar_seed[\s\S]*avatar_palette[\s\S]*avatar_renderer_version[\s\S]*\) on table public\.profiles to authenticated/);
assert.doesNotMatch(migration, /bytea|storage|bucket|upload/i);
});
test("account APIs expose and update Beam parameters through authenticated staging data", () => {
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
assert.match(accountRoute, /select\("avatar_seed,avatar_palette,avatar_renderer_version"\)/);
assert.match(accountRoute, /avatar,/);
assert.match(avatarRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
assert.match(avatarRoute, /createServerSupabaseClient\(\)/);
assert.match(avatarRoute, /auth\.getUser\(\)/);
assert.match(avatarRoute, /beamAvatarPatchSchema\.safeParse/);
assert.match(avatarRoute, /createBeamAvatarProfilePatch\(parsed\.data\)/);
assert.match(avatarRoute, /\.eq\("id", user\.id\)/);
assert.doesNotMatch(avatarRoute, /createAdminSupabaseClient|SUPABASE_SERVICE_ROLE_KEY|upload|bytea/i);
});
test("renders the persisted Beam avatar in the sidebar, account menu, and profile editor", () => {
assert.match(avatarComponent, /from "boring-avatars"/);
assert.match(avatarComponent, /variant="beam"/);
assert.match(avatarComponent, /name=\{avatar\.seed\}/);
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{32\}/);
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{40\}/);
assert.match(page, /Beam 形象由随机种子生成/);
assert.match(page, /role="radiogroup" aria-label="头像配色"/);
assert.match(page, /persistAvatar\(\{ action: "set_palette", palette: index \}\)/);
assert.match(page, /persistAvatar\(\{ action: "randomize" \}\)/);
assert.doesNotMatch(page, /avatar[^\n]{0,80}(?:file|upload|上传图片)/i);
});