The two large hero avatar frames go back to their legacy rounded-square look: the user center profile header keeps 6px and the public user home hero keeps var(--radius). Both declare an explicit radius because the global avatar primitive (img.avatar, 0,1,1) ties them on specificity and only the later route rule wins the cascade.
Everything else stays: the primitive still circles the bare avatar-mini in the ban and report modals, the VIP modal avatar and the post-card meta avatar, the friends page avatar is circular, and the VIP icon still leads the badge row. The contract test now guards the two hero frames as rounded-square so the primitive cannot silently re-circularize them.
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.
The posts/favorites tabs turn the article list into a 2-column grid at
>=992px. The AJAX pagination skeleton .post_ajax_loader is a direct
child of that grid, so with no grid-column it was laid out as a single
half-width card and the whole placeholder stack collapsed into one
column while paging or switching tabs.
Span the loader across every column and, while loading, give it the
same 2-column template the real cards use so placeholders line up with
them. The skeleton shape now follows the resolved render mode instead
of a hardcoded 'list', so card mode no longer renders list blocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clicking 加载更多 in the 多栏目文章 widget showed no skeleton at all — the
list simply stayed still until the response arrived, then the new cards
appeared. The homepage stream, archives and comments already had a working
skeleton; the widget's AJAX load goes through a different client and never
found its loader.
Root cause was DOM placement, not visibility: ContentRenderer emitted
.post_ajax_loader as a *sibling* of .widget-ajaxpager, while the widget REST
client resolves it with container.querySelector('.post_ajax_loader'). The
node existed but could never match, so .is-loading was never added and the
skeleton stayed display:none. Inactive tab panes had the same shape, and the
direct-load branch of ajaxpager() emitted no loader at all.
Changes, all following the reference theme's post_ajax() contract in
zibll/js/main.js (beforeSend anchors the loader to the end of the list;
success only hides it, never removes it):
- ContentRenderer: nest the loader inside .widget-ajaxpager for both the
direct-load and inactive-tab branches, and drop the sibling append.
- widgets/frontend.js: anchorLoader() moves the loader to the end of the
list before .ajax-pag on every request; appended cards are inserted before
it; a replace preserves the node instead of wiping it with innerHTML; a
fallback loader is created when the DOM has none; loadItems() now returns
its promise; drop the unreachable is-loading auto-load branch.
- Message centre, user-home panels and the search page switch from spinner /
opacity feedback to the same .posts-item-shaped skeleton, so all AJAX
surfaces share one loading contract.
- Remove the second, never-wired skeleton system (.aksrt-skeleton-* in
core-ui.css) and the dead spinner stylesheets/rules (skeleton.css,
ajax-spinner.css, .load-more__*, --skeleton-base/--skeleton-shine) plus the
unregistered assets/js/waterfall.js.
- Extend the widget, message-centre, user-home and asset-ledger contract
tests to pin the loader inside the container, the class-only visibility
switch, and the absence of the deleted dead code.
Verified: 470 node tests, 136 PHP widget assertions, admin-contract and
widget-system audits all green; rebuilt public assets after the CSS/JS
removals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loading indication now matches the reference theme's post_ajax() contract:
- Stop overwriting the 加载更多 button label with 正在加载...; the button
keeps its own text and the .post_ajax_loader skeleton carries the busy
state instead.
- Add the reference fallback loader (.loading ring + 加载中...) when no
skeleton exists in the DOM, and reinsert the skeleton after the last
card on every page load like beforeSend does.
- Retext the loader string to 加载中... (zib__('loading')) and make the
ajax_trigger default a translatable string.
- Route ajax-list.js / comments.js / widgets frontend.js through the
.is-loading class instead of inline display, which outranked the class
rule and could freeze the skeleton shut.
- Drop the search.php inline display:none that defeated .is-loading, and
remove the unproduced .post-ajax-loader / [data-aksrt-ajax-loader] shims.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clicking 加载更多 showed no skeleton at all, and the placeholder shape did
not match the reference theme's post cards.
- Class-name mismatch: pagination.js queried .post-ajax-loader while every
PHP producer emits .post_ajax_loader, so showSkeleton() never found a
target and the loader stayed hidden. Accept both spellings.
- Inline display:none on the loader (pagination, comments, widget tab
panes) outranked .is-loading, so even the correct selector could not
reveal it. Drop the inline style; the reference uses a bare
<div class="post_ajax_loader"> and toggles visibility from JS.
- loadNextPage() set indicator.style.display = "flex", which forced the
.posts-item.card skeleton blocks (inline-block layout) into a single
row instead of stacking vertically — the visible symptom. Remove those
inline writes and drive .is-loading from CSS for every surface.
- Scope the hide/show rules on the class itself rather than requiring an
.ajaxpager ancestor: homepage tab panes, archive streams and comment
lists sit at three different depths.
- Rebuild aksrt_render_placeholder() on zib_get_post_placeholder's
structure — list = t1/k1/k2 + two s1, card = t1 item-heading/k2 + two
s1, thumb wrapped in post-graphic + radius8 item-thumbnail, all carrying
ajax-item so the loader is not mistaken for a real card.
- Add mini/mini_thumb modes so ContentRenderer::placeholder() stops
hand-rolling the same markup; normalize its 'lists' arg to 'list'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>