feat(frontend): unify avatar shape, move VIP icon to badge row

Make --radius-avatar the single source of truth for frontend user avatar shape. A new primitive in components-ext.css (img.avatar / avatar-mini / avatar-md / avatar-lg / .avatar-img img) forces a circle on every avatar image, which covers the bare avatar-mini in the ban and report modals, the classless img inside the VIP modal's .avatar-img, and the post-card meta avatar that previously depended on post-card.css being enqueued. The user center header frame, the public user home hero and the friends page avatar switch to the same token because route CSS loads after the public bundle and would otherwise override the primitive.

The VIP icon no longer rides the avatar corner. The user card widget, the after-article author card and the sign-in modal stop opting into aksrt_get_avatar_box()'s corner badge, and the icon renders first in the badge/level row instead, since aksrt_get_user_name already orders name -> VIP -> auth -> medal -> level. The sign-in modal appends it after the display name and keeps the legacy precedence where a ban badge takes the VIP slot. The user card widget name row now shrinks its display name so the extra icon cannot overflow a narrow sidebar.

Verified with the new frontend-avatar-shape-contract test plus post-visual, user-card-runtime, circular-button-icon-margin, user-features and page-display contracts, and php -l on every changed PHP file. The public bundle was rebuilt with tools/build-public-assets.mjs.
This commit is contained in:
2026-10-10 13:53:07 +08:00
parent 16b57dcf1c
commit 9cb5e7bea3
14 changed files with 181 additions and 22 deletions
@@ -0,0 +1,122 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
// --radius-avatar is the single source of truth for frontend avatar shape.
// Route CSS loads after the public bundle, and `.uc-header__avatar img` /
// `.aksrt-author-hero__avatar img` tie the primitive at 0,1,1 specificity,
// so every framed avatar must opt in explicitly.
const primitiveSelectors = [
'img.avatar,',
'img.avatar-mini,',
'img.avatar-md,',
'img.avatar-lg,',
'.avatar-img img {',
];
test('the global avatar shape primitive renders every avatar image circular', () => {
const css = read('assets/css/core/components-ext.css');
const start = css.indexOf('img.avatar,');
assert.notEqual(start, -1, 'missing the global avatar shape primitive');
const block = css.slice(start, css.indexOf('}', start));
for (const selector of primitiveSelectors) {
assert.ok(block.includes(selector), `primitive is missing selector: ${selector}`);
}
assert.match(block, /border-radius:\s*var\(--radius-avatar,\s*50%\)/);
assert.match(block, /object-fit:\s*cover/);
// The primitive must follow the existing avatar size system in the same file.
const sizeSystem = css.indexOf('.aksrt-avatar--xs {');
assert.ok(sizeSystem >= 0 && sizeSystem < start, 'primitive must sit after the avatar size system');
});
test('user center and public user home avatar frames are circular', () => {
const profile = read('assets/css/routes/profile/layout.css');
const userHome = read('assets/css/routes/user-home.css');
const friends = read('assets/css/routes/friends.css');
assert.match(profile, /\.uc-header__avatar\s*\{[^}]*border-radius:\s*var\(--radius-avatar,\s*50%\)/s);
assert.doesNotMatch(profile, /\.uc-header__avatar\s*\{[^}]*border-radius:\s*6px/s);
// img and the upload overlay both inherit the frame radius.
assert.match(profile, /\.uc-header__avatar img\s*\{[^}]*border-radius:\s*inherit/s);
assert.match(userHome, /\.aksrt-author-hero__avatar img\s*\{[^}]*border-radius:\s*var\(--radius-avatar,\s*50%\)/s);
assert.doesNotMatch(userHome, /\.aksrt-author-hero__avatar img\s*\{[^}]*border-radius:\s*var\(--radius\)/s);
assert.match(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius-avatar,\s*50%\)/s);
assert.doesNotMatch(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius\)/s);
});
test('the user card name row can shrink once the VIP icon joins the badge row', () => {
const base = read('assets/css/frontend/base.css');
const start = base.indexOf('.user-card.author .user-name > name > .display-name,');
assert.notEqual(start, -1, 'missing the display-name shrink rule');
const block = base.slice(start, base.indexOf('}', start));
assert.ok(block.includes('.user-card.widget .user-name > name > .display-name'), 'shrink rule must cover the widget card too');
assert.match(block, /min-width:\s*0/);
assert.match(block, /overflow:\s*hidden/);
assert.match(block, /text-overflow:\s*ellipsis/);
});
test('the VIP modal avatar carries the canonical avatar class', () => {
const payVip = read('assets/js/pay-vip.js');
assert.match(payVip, /imageNode\(text\(member\.avatar_url\), "avatar"\)/);
assert.doesNotMatch(payVip, /var avatar = node\("img"\)/);
});
test('the VIP icon leaves the avatar corner and leads the badge row', () => {
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
const singleRender = read('inc/helpers/aksrt-single-render.php');
const signin = read('src/Credits/Signin.php');
for (const [file, source] of [['UserRenderer.php', userRenderer], ['aksrt-single-render.php', singleRender]]) {
assert.match(source, /aksrt_get_avatar_box\([^)]*'avatar-img avatar-lg',\s*true,\s*false\)/, `${file} must drop the avatar corner badge`);
assert.match(source, /'vip'\s+=>\s+true/, `${file} must render the VIP icon in the name row`);
assert.doesNotMatch(source, /'vip'\s+=>\s+false/);
}
assert.match(signin, /aksrt_get_avatar_box\(\$user_id,\s*'avatar-img avatar-mini mr6',\s*false,\s*false\)/);
assert.match(signin, /aksrt_get_user_ban_name_badge\(\$user_id\)/);
assert.match(signin, /aksrt_get_vip_icon\(\$user_id\)/);
assert.match(signin, /\$avatar_img \. esc_html\(\$user->display_name\) \. \$name_badge/);
});
test('no frontend call site opts into the avatar corner VIP badge', () => {
const offenders = [];
const skip = new Set(['node_modules', 'vendor', '.git', 'tests', 'tools']);
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (skip.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!/\.(php|js)$/.test(entry.name) || entry.name.endsWith('.min.js')) continue;
const source = fs.readFileSync(full, 'utf8');
const pattern = /aksrt_get_avatar_box\([^;]*?,\s*(?:true|false),\s*true\s*\)/g;
for (const match of source.matchAll(pattern)) {
offenders.push(`${path.relative(root, full).split(path.sep).join('/')}: ${match[0]}`);
}
}
};
walk(root);
assert.deepEqual(offenders, [], 'aksrt_get_avatar_box() must no longer mount a badge on the avatar');
});
test('the avatar helper keeps its corner badge contract for callers that still want it', () => {
const avatar = read('inc/helpers/aksrt-avatar.php');
assert.match(avatar, /function aksrt_get_avatar_box\(\$id_or_email, string \$class = 'avatar-img', bool \$link = true, bool \$vip = true\)/);
assert.match(avatar, /class="avatar-badge"/);
});
test('the built public bundle carries the minified primitive', () => {
const bundle = read('assets/css/build/public.min.css');
// esbuild keeps the fallback list spacing inside var() untouched.
const minified = 'img.avatar,img.avatar-mini,img.avatar-md,img.avatar-lg,.avatar-img img{border-radius:var(--radius-avatar, 50%);object-fit:cover}';
assert.notEqual(bundle.indexOf(minified), -1, 'missing the minified avatar shape primitive');
});
@@ -301,7 +301,7 @@ test('after-box author card mirrors legacy user-card structure', () => {
const frontendTags = read('inc/helpers/aksrt-frontend-tags.php');
assert.match(render, /avatar-img avatar-lg/);
assert.match(render, /'vip'\s+=>\s+false/);
assert.match(render, /'vip'\s+=>\s+true/);
assert.match(render, /'follow'\s+=>\s+true/);
assert.match(render, /<div class="author-tag mt10 mini-scrollbar">/);
assert.match(render, /<div class="user-desc mt10 muted-2-color em09">/);
@@ -101,8 +101,9 @@ namespace {
unset($subject);
return '<img class="' . esc_attr((string) ($attrs['class'] ?? 'avatar')) . '" alt="' . esc_attr((string) ($attrs['alt'] ?? '')) . '" width="' . $size . '" height="' . $size . '">';
}
function aksrt_get_avatar_box($user_id, string $class): string {
return '<a href="https://example.test/user/' . (int) $user_id . '"><span class="' . esc_attr($class) . '"><img class="avatar"></span></a>';
function aksrt_get_avatar_box($user_id, string $class, bool $link = true, bool $vip = true): string {
$inner = '<span class="' . esc_attr($class) . '"><img class="avatar"></span>';
return $link ? '<a href="https://example.test/user/' . (int) $user_id . '">' . $inner . '</a>' : $inner;
}
function aksrt_get_user_name(array $args): string {
return '<name class="' . esc_attr((string) ($args['class'] ?? '')) . '"><a class="display-name">User ' . (int) ($args['id'] ?? 0) . '</a></name>';