fix(pagination): align the load-more skeleton width with the cards

首页「加载更多」的骨架比列表本身宽出一截:.posts-row 用
margin-left/right:-8px 抵消卡片的水平边距,而卡片边距规则是
`.posts-row > *:not(.post_ajax_loader)`,把骨架排除在补偿链之外,
于是骨架左右各宽出 8px(合计 16px)。移动端的 5px 覆盖块同理。

小工具的 .widget-ajaxpager / tab pane 也是一条独立的负边距补偿链
(margin: -8px 抵消 .posts-item 的 8px),骨架同样溢出。

修法是给骨架补上与卡片等同的水平边距,而不是写 inline display
(会盖过 .is-loading 类规则)。改了 main.css、post-card.css 的
8px / 5px 两组规则,用户中心面板另加一条归零规则,因为那里的
容器 margin 与卡片边距都已清零,不能再叠加补偿。

顺带补一条契约测试:删掉任一 gutter 规则即失败,防止回归。

Verification:
- node --test tests/**/*.test.cjs  471 passed / 0 failed
- php tests/widget-system/run.php  136 assertions
- php tools/audit-widget-system.php
- php tools/audit-admin-contracts.php  errors=0 (56 既有 baseline 警告)
- node tools/build-public-assets.mjs  CSS/JS 产物已重建

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:25:26 +08:00
co-authored by Claude Fable 5
parent cf4554e7c8
commit f61142ee9a
8 changed files with 153 additions and 3 deletions
@@ -92,6 +92,64 @@ test('skeleton loading ships one contract and no dead spinner stylesheets', () =
}
})
test('skeleton blocks share the card gutter instead of overflowing it', () => {
// .posts-row pulls the container in with a negative margin and hands every
// card a matching gutter. The card rule is scoped
// `.posts-row > *:not(.post_ajax_loader)`, so the loader is excluded from the
// compensation chain and ends up wider than the cards by both gutters.
// Each gutter block therefore needs a sibling rule that gives the loader the
// same horizontal margin — otherwise 加载更多 shows a skeleton that spills
// outside the content column.
const blocks = [
{
file: 'assets/css/main.css',
selector: '.posts-row > .post_ajax_loader',
gutters: [8, 5],
},
{
file: 'assets/css/frontend/post-card.css',
selector: '.posts-row > .post_ajax_loader',
gutters: [8, 5],
},
]
for (const block of blocks) {
const css = fs.readFileSync(path.join(root, block.file), 'utf8')
const rules = (css.match(
new RegExp(`${block.selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}`, 'g')
) || []).map((rule) => rule.replace(/\s+/g, ' '))
// One rule per gutter value in that file (desktop 8px, mobile 5px).
for (const gutter of block.gutters) {
const matched = rules.some(
(rule) =>
rule.includes(`margin-left: ${gutter}px`) &&
rule.includes(`margin-right: ${gutter}px`)
)
assert.ok(
matched,
`${block.file} must give .post_ajax_loader the ${gutter}px gutter in "${block.selector}"`
)
}
}
// The widget pagers apply their own negative-margin compensation chain
// (.widget-ajaxpager / .tab-pane), so the loader needs the same relief there.
for (const file of ['assets/css/main.css', 'assets/css/frontend/post-card.css']) {
const css = fs.readFileSync(path.join(root, file), 'utf8')
// Cover every style class the renderer can emit (.style-list / .style-card,
// and the tab pane which carries style-list or index-tab).
assert.match(css, /\.widget-main-post\.style-list \.widget-ajaxpager > \.post_ajax_loader/)
assert.match(css, /\.widget-main-post\.style-card \.widget-ajaxpager > \.post_ajax_loader/)
assert.match(css, /\.widget-tab-post \.tab-content > \.tab-pane > \.widget-ajaxpager > \.post_ajax_loader/)
}
// The user-home panel zeroes the container margin and the card gutters, so the
// loader must not pick up any leftover compensation there.
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 \{\s*margin-right: 0;\s*margin-left: 0;/)
})
test('asset tables are InnoDB and operation keys are unique', () => {
const schema = fs.readFileSync(path.join(root, 'src', 'Core', 'DBMigratorPart2.php'), 'utf8')
for (const table of ['asset_account', 'asset_transaction', 'asset_entry']) {