diff --git a/assets/css/routes/user-home.css b/assets/css/routes/user-home.css
index b6bb1d95..0d0b3f70 100644
--- a/assets/css/routes/user-home.css
+++ b/assets/css/routes/user-home.css
@@ -391,11 +391,32 @@
margin: 0 !important; /* 抵消 vendored .posts-row > * 的 8px !important 边距 */
}
+ /* 骨架是网格容器自己的直接子节点:不显式跨列,它就会被当成一张卡片,
+ 只分到一半宽度,整摞骨架全挤在单列里(翻页 / 切 tab 的占位因此变形)。
+ 跨满全部列,并在加载态把 loader 自身变成与真实卡片同款的双列网格,
+ 骨架行才会与卡片同宽、同轴、同列数。display 只随 is-loading 打开,
+ 未加载时容器仍是 display:none,不会被提前撑开。 */
+ .aksrt-user-home .aksrt-user-home__post-list[data-view="list"] > .post_ajax_loader {
+ grid-column: 1 / -1;
+ }
+
+ .aksrt-user-home .dashboard-panel.is-loading .aksrt-user-home__post-list[data-view="list"] > .post_ajax_loader {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+ }
+
/* 骨架跟随网格走:容器 margin 已归零、卡片边距已清零,
- 不能再叠加 .posts-row > .post_ajax_loader 内层的 8px 补偿。 */
+ 不能再叠加 .posts-row > .post_ajax_loader 内层的 8px 补偿;
+ 宽高沿用真实卡片的网格单元,所以 vendored site-layout-1 的
+ calc(50% - 16px) 与剩余边距也要一并清零。 */
.aksrt-user-home .aksrt-user-home__post-list[data-view="list"] > .post_ajax_loader .posts-item {
margin-right: 0;
margin-left: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+ width: auto;
}
}
diff --git a/templates/parts/user-home/panel-favorites.php b/templates/parts/user-home/panel-favorites.php
index df7f81d3..e18e86c4 100644
--- a/templates/parts/user-home/panel-favorites.php
+++ b/templates/parts/user-home/panel-favorites.php
@@ -35,9 +35,10 @@ if (!empty($favorite_post_ids)) :
'update_post_meta_cache' => false,
]);
// 面板 AJAX 翻页期间由 author.js 切 .is-loading,骨架沿用文章列表的
- // .post_ajax_loader 契约。
+ // .post_ajax_loader 契约。形状跟随同一份渲染配置(list / card):
+ // 卡片模式下若仍渲染列表块,骨架就与真实卡片不同轴。
$user_home_panel_loader = function_exists('aksrt_render_placeholder')
- ? '
' . aksrt_render_placeholder('list', max(4, (int) $per_page)) . '
'
+ ? '' . aksrt_render_placeholder((string) ($user_home_render_config['mode'] ?? 'list'), max(4, (int) $per_page)) . '
'
: '';
aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list', 'append_inner_html' => $user_home_panel_loader]), $favorites_query);
aksrt_render_user_home_pagination((int) $favorites_query->max_num_pages, (int) $favorites_page);
diff --git a/templates/parts/user-home/panel-posts.php b/templates/parts/user-home/panel-posts.php
index 763f5f7c..2b322f25 100644
--- a/templates/parts/user-home/panel-posts.php
+++ b/templates/parts/user-home/panel-posts.php
@@ -30,9 +30,10 @@ $author_posts = new WP_Query([
'no_found_rows' => false,
]);
// 面板 AJAX 翻页期间由 author.js 切 .is-loading,骨架沿用文章列表的
-// .post_ajax_loader 契约。
+// .post_ajax_loader 契约。形状跟随同一份渲染配置(list / card):
+// 卡片模式下若仍渲染列表块,骨架就与真实卡片不同轴。
$user_home_panel_loader = function_exists('aksrt_render_placeholder')
- ? '' . aksrt_render_placeholder('list', max(4, (int) $per_page)) . '
'
+ ? '' . aksrt_render_placeholder((string) ($user_home_render_config['mode'] ?? 'list'), max(4, (int) $per_page)) . '
'
: '';
aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list', 'append_inner_html' => $user_home_panel_loader]), $author_posts);
aksrt_render_user_home_pagination((int) $author_posts->max_num_pages, (int) $posts_page);
diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs
index 0ddae897..eb4f42e2 100644
--- a/tests/widget-system/asset-ledger-contract.test.cjs
+++ b/tests/widget-system/asset-ledger-contract.test.cjs
@@ -154,8 +154,19 @@ test('skeleton blocks share the card gutter instead of overflowing it', () => {
// The user-home panel zeroes the container margin and the card gutters, so the
// skeleton's inner item must not pick up any leftover compensation there.
+ //
+ // The panel also turns the list into a 2-column grid at >=992px. The loader is
+ // a direct child of that grid, so without an explicit span it is laid out as a
+ // single half-width card and the whole skeleton stack collapses into one
+ // column. It must span every column and re-run the same 2-column template
+ // internally while loading, so the placeholders line up with the real cards.
const userHome = fs.readFileSync(path.join(root, 'assets/css/routes/user-home.css'), 'utf8')
assert.match(userHome, /\.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \.posts-item \{\s*margin-right: 0;\s*margin-left: 0;/)
+ assert.match(userHome, /\.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \{\s*grid-column: 1 \/ -1;/)
+ assert.match(
+ userHome,
+ /\.dashboard-panel\.is-loading \.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \{[^}]*display: grid;[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/s
+ )
})
test('asset tables are InnoDB and operation keys are unique', () => {
diff --git a/tests/widget-system/post-visual-contract.test.cjs b/tests/widget-system/post-visual-contract.test.cjs
index 73e97199..f1ef9029 100644
--- a/tests/widget-system/post-visual-contract.test.cjs
+++ b/tests/widget-system/post-visual-contract.test.cjs
@@ -258,9 +258,14 @@ test('related posts, author page and widgets use their dedicated renderers', ()
assert.doesNotMatch(author, /aksrt_render_pagination|aksrt_render_profile_pagination|\$_GET|add_query_arg/);
// 面板 AJAX 翻页的骨架:与文章列表同一个 .post_ajax_loader 契约,
// 由 author.js 的 .is-loading 切换(不能再靠 opacity 变暗反馈)。
+ // 骨架形状必须跟随同一份渲染配置:写死 'list' 会让卡片模式下的占位
+ // 与真实卡片不同轴。
assert.match(author, /'append_inner_html' => \$user_home_panel_loader/);
- assert.match(author, /aksrt_render_placeholder\('list', max\(4, \(int\) \$per_page\)\)/);
+ assert.match(author, /aksrt_render_placeholder\(\(string\) \(\$user_home_render_config\['mode'\] \?\? 'list'\), max\(4, \(int\) \$per_page\)\)/);
+ assert.doesNotMatch(author, /aksrt_render_placeholder\('(list|card)'/);
assert.match(authorFavorites, /'append_inner_html' => \$user_home_panel_loader/);
+ assert.match(authorFavorites, /aksrt_render_placeholder\(\(string\) \(\$user_home_render_config\['mode'\] \?\? 'list'\), max\(4, \(int\) \$per_page\)\)/);
+ assert.doesNotMatch(authorFavorites, /aksrt_render_placeholder\('(list|card)'/);
assert.match(userHomeCss, /\.aksrt-user-home \.dashboard-panel \.post_ajax_loader \{/);
assert.match(userHomeCss, /\.aksrt-user-home \.dashboard-panel\.is-loading \.post_ajax_loader \{/);
assert.doesNotMatch(userHomeCss, /\.dashboard-panel\.is-loading \{[\s\S]{0,80}opacity: \.6/);
@@ -477,7 +482,7 @@ test('profile routes load versioned leaf styles and keep dialogs hidden initiall
assert.match(panelFavorites, /'context'\s*=>\s*'author'/);
assert.match(panelFavorites, /aksrt_render_user_home_pagination\(\(int\) \$favorites_query->max_num_pages, \(int\) \$favorites_page\)/);
assert.match(panelFavorites, /array_merge\(\$user_home_render_config, \['wrapper_class' => 'aksrt-user-home__post-list', 'append_inner_html' => \$user_home_panel_loader\]\), \$favorites_query\)/);
- assert.match(panelFavorites, /aksrt_render_placeholder\('list', max\(4, \(int\) \$per_page\)\)/);
+ assert.match(panelFavorites, /aksrt_render_placeholder\(\(string\) \(\$user_home_render_config\['mode'\] \?\? 'list'\), max\(4, \(int\) \$per_page\)\)/);
assert.match(read('templates/parts/user-home/panel-posts.php'), /array_merge\(\$user_home_render_config, \['wrapper_class' => 'aksrt-user-home__post-list', 'append_inner_html' => \$user_home_panel_loader\]\), \$author_posts\)/);
assert.doesNotMatch(panelFavorites, /profile-list-item|\$_GET|add_query_arg/);
// Only the two-column user center spans the viewport; the single-column public