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
+17 -10
View File
@@ -16,8 +16,12 @@
}
/* 骨架与真实卡片同宽:.posts-row 以 -8px 抵消卡片边距,loader 被上面的
:not() 排除在补偿外,会宽出 16px;这里补上等同的水平边距。 */
.posts-row > .post_ajax_loader {
:not() 排除在补偿外,会宽出 16px;这里补上等同的水平边距。
补偿落在骨架内层的 .posts-item 上(与真实卡片同一元素),不落外层
.post_ajax_loader —— 否则内层 .posts-item.card 的 margin:8px 会与外层
补偿叠成 16px,小工具 card 骨架因此窄出 8px。 */
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 8px;
margin-left: 8px;
}
@@ -456,10 +460,12 @@
}
/* 骨架与卡片同宽同轴:.widget-ajaxpager / .tab-pane 用负边距抵消卡片的水平
边距,骨架被排除在补偿链外,于是左右各宽出一个 gutter。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
边距。补偿同样落在骨架内层的 .posts-item 上(与真实卡片同一元素),
不落外层 .post_ajax_loader —— 否则内层 .posts-item.card 自带的 margin:8px
会与外层补偿叠成 16px,card 骨架因此窄出 8px。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 8px;
margin-left: 8px;
}
@@ -534,7 +540,8 @@
margin-left: 5px !important;
}
.posts-row > .post_ajax_loader {
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 5px;
margin-left: 5px;
}
@@ -585,9 +592,9 @@
margin-left: 5px;
}
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 5px;
margin-left: 5px;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -10
View File
@@ -15,8 +15,12 @@
}
/* 骨架与真实卡片同宽:.posts-row 以 -8px 抵消卡片边距,loader 被上面的
:not() 排除在补偿外,会宽出 16px;这里补上等同的水平边距。 */
.posts-row > .post_ajax_loader {
:not() 排除在补偿外,会宽出 16px;这里补上等同的水平边距。
补偿落在骨架内层的 .posts-item 上(与真实卡片同一元素),不落外层
.post_ajax_loader —— 否则内层 .posts-item.card 的 margin:8px 会与外层
补偿叠成 16px,小工具 card 骨架因此窄出 8px。 */
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 8px;
margin-left: 8px;
}
@@ -455,10 +459,12 @@
}
/* 骨架与卡片同宽同轴:.widget-ajaxpager / .tab-pane 用负边距抵消卡片的水平
边距,骨架被排除在补偿链外,于是左右各宽出一个 gutter。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
边距。补偿同样落在骨架内层的 .posts-item 上(与真实卡片同一元素),
不落外层 .post_ajax_loader —— 否则内层 .posts-item.card 自带的 margin:8px
会与外层补偿叠成 16px,card 骨架因此窄出 8px。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 8px;
margin-left: 8px;
}
@@ -533,7 +539,8 @@
margin-left: 5px !important;
}
.posts-row > .post_ajax_loader {
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 5px;
margin-left: 5px;
}
@@ -584,9 +591,9 @@
margin-left: 5px;
}
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 5px;
margin-left: 5px;
}
+18 -11
View File
@@ -7487,8 +7487,12 @@ badge.top,
}
/* 骨架与真实卡片同宽同轴:.posts-row 用 -8px 抵消卡片边距,原 :not() 把骨架
排除在补偿外,骨架于是比卡片左右各宽 8px。补上等同边距。 */
.posts-row > .post_ajax_loader {
排除在补偿外,骨架于是比卡片左右各宽 8px。补上等同边距。
注意补偿落在骨架**内层**的 .posts-item 上(与真实卡片同一元素、同一选择器),
不能落在外层 .post_ajax_loader 上 —— 骨架内层本身带 .posts-item.card 的
margin:8px,外层再补 8px 就是双重补偿,小工具 card 骨架会窄出 8px。 */
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-left: 8px;
margin-right: 8px;
}
@@ -7898,11 +7902,13 @@ badge.top,
}
/* 骨架与卡片同宽同轴:.widget-ajaxpager / .tab-pane 用负边距抵消卡片的水平
边距,骨架被排除在补偿链外,于是左右各宽出一个 gutter。这里补回等宽的
边距,让骨架与真实卡片对齐。tab pane 的 style-list / index-tab 都适用。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
边距。补偿同样落在骨架内层的 .posts-item 上(与真实卡片同一元素),
而不是外层 .post_ajax_loader —— 否则骨架内层自带的 .posts-item.card
margin:8px 会与外层补偿叠成 16px,让 card 骨架窄出 8px。
style-card 的卡片由 .posts-item.card 自带 margin:8px,这里只需覆盖骨架内层。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-left: 8px;
margin-right: 8px;
}
@@ -11230,9 +11236,9 @@ body.error404 {
margin-bottom: 30px;
}
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader {
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-left: 5px;
margin-right: 5px;
}
@@ -11247,7 +11253,8 @@ body.error404 {
margin-right: 5px !important;
}
.posts-row > .post_ajax_loader {
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-left: 5px;
margin-right: 5px;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -392,8 +392,8 @@
}
/* 骨架跟随网格走:容器 margin 已归零、卡片边距已清零,
不能再叠加 .posts-row > .post_ajax_loader 的 8px 补偿。 */
.aksrt-user-home .aksrt-user-home__post-list[data-view="list"] > .post_ajax_loader {
不能再叠加 .posts-row > .post_ajax_loader 内层的 8px 补偿。 */
.aksrt-user-home .aksrt-user-home__post-list[data-view="list"] > .post_ajax_loader .posts-item {
margin-right: 0;
margin-left: 0;
}
@@ -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', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-10-03T18:57:46+00:00",
"generated_at": "2026-10-03T20:06:15+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",