fix(skeleton): put the widget gutter relief on the skeleton's inner item

小工具(首页挂载的 / 侧栏的)骨架加载占位比真实卡片窄一截:card 布局下
每侧窄 8px,视觉上就是「骨架小了一些」。

小工具的卡片走 skip_wrapper=true,直接挂在 .widget-ajaxpager 上
(src/WidgetSystem/PostRenderer.php),不经过 .posts-row,因此只受小工具
这条独立的负边距链作用:

  .widget-ajaxpager            margin: -8px
  .posts-item.card(真实卡片)  margin: 8px   ← 卡片自身自带
  .post_ajax_loader(外层)     margin: 8px   ← f61142ee 加的补偿
  .posts-item.card(骨架内层)  margin: 8px   ← 同一条卡片规则

骨架于是拿到 8px(外层 loader)+ 8px(内层卡片)= 16px,而真实卡片只有
8px,内容区每侧少 8px。list 模式没暴露是因为 .posts-item 的 8px 来自
`.widget-main-post.style-list .posts-item`,该规则只覆盖 style-list、
不作用于骨架,外层的 8px 恰好补上 list 卡片应有的一份,刚好对齐。

修法:把水平补偿从外层 .post_ajax_loader 移到骨架内层的 .posts-item /
.posts-mini —— 与真实卡片同一个元素、同一条选择器,于是两者逐像素同宽,
也顺带回答了「补偿链的拥有者是谁」这个一直在打补丁的问题。

- assets/css/main.css、assets/css/frontend/post-card.css:.posts-row 与
  .widget-ajaxpager 两条链、桌面 8px 与移动 5px 两组,共 8 处规则改为
  内层选择器。
- assets/css/routes/user-home.css:面板归零规则同步改到内层,否则它归零
  的是已经失效的外层,旧补偿会重新显形。
- tests/widget-system/asset-ledger-contract.test.cjs:把守卫断言改成内层
  选择器,并修正规则匹配以支持选择器组(.posts-item, .posts-mini)。
  注释说明为何不能补偿外层。

参考主题 zibll 完全没有 .post_ajax_loader 的 margin 规则;AKSRT 需要它,
是因为骨架多包了一层。定位在内层是与参考主题等价的正确解。

Verification:
- node tests/widget-system/asset-ledger-contract.test.cjs  16/16
- node tests/widget-system/post-visual-contract.test.cjs   22/22
- node tests/widget-system/zibll-modern-widgets-contract.test.cjs 10/10
- node tests/widget-system/lazyload-contract.test.cjs      7/7
- node tests/widget-system/frontend-lifecycle-contract.test.cjs 13/13
- node tests/widget-system/page-display-contract.test.cjs  18/18
- node tests/widget-system/message-center-contract.test.cjs 8/8
- php tools/audit-admin-contracts.php  errors=0
- build:public 已重建;旧外层规则在源与产物中均已归零

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 04:07:19 +08:00
co-authored by Claude Fable 5
parent 38bbd917a9
commit 20fde1d652
8 changed files with 78 additions and 49 deletions
@@ -97,26 +97,33 @@ test('skeleton blocks share the card gutter instead of overflowing it', () => {
// 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.
//
// The relief must land on the skeleton's INNER `.posts-item` — the same
// element and selector the real card uses — never on the outer
// `.post_ajax_loader`. A block skeleton is `> .post_ajax_loader > .mb20 >
// .posts-item.card`, and `.posts-item.card` already carries margin:8px of its
// own: compensating the outer wrapper too would stack 8px + 8px and make the
// card skeleton 8px narrower per side than a real card (the widget card
// layout, which skips .posts-row and relies on the .widget-ajaxpager chain).
const blocks = [
{
file: 'assets/css/main.css',
selector: '.posts-row > .post_ajax_loader',
selector: '.posts-row > .post_ajax_loader .posts-item',
gutters: [8, 5],
},
{
file: 'assets/css/frontend/post-card.css',
selector: '.posts-row > .post_ajax_loader',
selector: '.posts-row > .post_ajax_loader .posts-item',
gutters: [8, 5],
},
]
for (const block of blocks) {
const css = fs.readFileSync(path.join(root, block.file), 'utf8')
// The rule ships a selector group (`.posts-item, .posts-mini`), so match the
// whole group up to its `{` instead of assuming the selector is alone.
const rules = (css.match(
new RegExp(`${block.selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}`, 'g')
new RegExp(`${block.selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^{]*\\{[^}]*\\}`, 'g')
) || []).map((rule) => rule.replace(/\s+/g, ' '))
// One rule per gutter value in that file (desktop 8px, mobile 5px).
@@ -128,26 +135,27 @@ test('skeleton blocks share the card gutter instead of overflowing it', () => {
)
assert.ok(
matched,
`${block.file} must give .post_ajax_loader the ${gutter}px gutter in "${block.selector}"`
`${block.file} must give .post_ajax_loader .posts-item 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.
// (.widget-ajaxpager / .tab-pane), so the skeleton's inner item needs the same
// relief there — again on .posts-item, not on the loader wrapper.
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/)
assert.match(css, /\.widget-main-post\.style-list \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
assert.match(css, /\.widget-main-post\.style-card \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
assert.match(css, /\.widget-tab-post \.tab-content > \.tab-pane > \.widget-ajaxpager > \.post_ajax_loader \.posts-item/)
}
// The user-home panel zeroes the container margin and the card gutters, so the
// loader must not pick up any leftover compensation there.
// skeleton's inner item 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;/)
assert.match(userHome, /\.aksrt-user-home__post-list\[data-view="list"\] > \.post_ajax_loader \.posts-item \{\s*margin-right: 0;\s*margin-left: 0;/)
})
test('asset tables are InnoDB and operation keys are unique', () => {