fix: fixed opaque sticky gap; replaced transparent margin with page header padding; card headers dock below measured page header height; 8px gap is now part of the opaque page header background

修复不透明粘性间隙;
使用页面标题内边距(padding)替换透明外边距(margin);
卡片标题现在停靠在已测量页面标题高度下方;
8px 间隙现在是不透明页面标题背景的一部分。
This commit is contained in:
Dusk 2026-04-25 22:38:08 +08:00
parent cdfbf2d12c
commit 0955ba952d
4 changed files with 181 additions and 6 deletions

View file

@ -0,0 +1,97 @@
# 设置页 Sticky 不透明间距修复决策
本文锁定本轮“8px 间距属于不透明页面标题背景”的最终实现策略。
## 1. 共享常量决策
- 保留并继续使用:
- `SETTINGS_STICKY_CARD_GAP_PX = 8`
- `SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX = 64`
原因:
- 这两个常量已经是当前 sticky 布局的统一输入,不需要再引入新的数值来源。
## 2. CSS 变量决策
- `containerEl` 继续写入:
- `--ahs-settings-sticky-card-gap: 8px`
- `--ahs-settings-page-header-height: 64px`
- 页面标题高度变量继续由测量结果覆盖。
原因:
- gap 变量仍需要参与 pageHeader 底部 padding 计算。
- 卡片标题 sticky `top` 仍需要使用页面标题高度变量作为最终停靠位置。
## 3. 页面标题不透明间距策略
- 页面标题继续保持:
- `position: sticky`
- `top: 0px`
- `zIndex: 300`
- 不透明背景
- 取消透明 `marginBottom` 作为间距来源。
- 改为:
```ts
paddingTop = "12px"
paddingBottom = "calc(12px + var(--ahs-settings-sticky-card-gap, 8px))"
marginBottom = "0"
```
原因:
- 8px 视觉间距必须属于 pageHeader 自身背景绘制区域,否则无法阻止下层内容透出。
## 4. 保留现有 mask 策略
- 保留 `settingsPageHeaderMask`
- 继续使用相同不透明背景,并跟随 pageHeader sticky。
原因:
- 这层 mask 负责遮住标题上方和左右透明缺口,本轮问题不在这里,不需要重做。
## 5. 卡片标题 sticky top 策略
- 每个 `settingsCardToggle` 继续 sticky。
- `top` 改回:
```ts
var(--ahs-settings-page-header-height, 64px)
```
- 不再额外 `+ 8px`
原因:
- 8px gap 已经被吸收到 pageHeader 自身 padding 中。
- `--ahs-settings-page-header-height` 的真实测量值会包含这段 padding。
- 若继续额外加 gap会多出一段额外间距。
## 6. 测量与 cleanup 决策
- 继续复用已有 `ResizeObserver``getBoundingClientRect().height`
- `hide()` 继续断开 observer。
原因:
- 现有测量路径已经满足“测量包含 padding 的真实 header 高度”的需求。
## 7. 不变边界
- 不改 DOM 结构。
- 不改 `toggleCard()`、`renderCard()`、`expandedCardIds`、`bodyEl.empty()`。
- 不改局部刷新逻辑。
- 不改设置数据结构和 Anki 同步逻辑。
- 不引入 scroll listener、`IntersectionObserver` 或 `requestAnimationFrame`
## 8. 与上一轮 sticky gap 方案的差异
- 上一轮把 8px 间距放在 pageHeader 外部 margin并让 card header `top` 额外加 gap。
- 本轮改为让 8px 成为 pageHeader 自身不透明 padding 的一部分。
原因:
- 新截图证明仅固定数值还不够,间距承载层也必须是不透明的 header 本体。

View file

@ -0,0 +1,75 @@
# 设置页 Sticky 不透明间距差距审计
本文基于当前仓库真实实现,定位页面级 `Anki Heading Sync` 标题栏与卡片标题栏之间 8px 间距为何仍然会透出下层内容。
## 1. 当前 8px 视觉间距仍来自 `pageHeaderEl.style.marginBottom`
- 入口仍在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。
- 当前 `display()` 中页面标题 `pageHeaderEl` 已经是 sticky且背景不透明。
- 但标题与第一张卡片标题之间的 8px 间距目前仍由:
```ts
pageHeaderEl.style.marginBottom = SETTINGS_STICKY_CARD_GAP_VALUE;
```
提供。
这意味着当前 8px 仍然属于 header 外部透明 margin而不是 header 自身不透明背景的一部分。
## 2. 透明 margin 正是截图里透视的根因
- CSS margin 不属于元素背景绘制区域。
- 因此即使 `pageHeaderEl` 本体有不透明背景margin 区域也仍然是透明的。
- 当前截图里 `Anki Heading Sync` 与卡片标题之间会露出下层文字,和这一路径完全一致。
结论:只要这段 8px 继续由 `marginBottom` 提供,就无法做到“不透明的 sticky 标题间距”。
## 3. 当前卡片标题 sticky top 还额外加上了 gap
- 当前每个 `settingsCardToggle` 已经是 sticky。
- 但其 `top` 现在写的是:
```ts
calc(var(--ahs-settings-page-header-height, 64px) + var(--ahs-settings-sticky-card-gap, 8px))
```
- 这表示卡片 sticky 偏移仍把 gap 视作“header 外部额外距离”。
如果本轮把 8px 合并进 pageHeader 自身 padding那么卡片标题 `top` 就必须回到:
```ts
var(--ahs-settings-page-header-height, 64px)
```
否则会重复多算一次 8px。
## 4. 当前页面标题高度测量路径已经可以复用
- 当前仓库已经具备:
- `--ahs-settings-page-header-height`
- `ResizeObserver`
- `getBoundingClientRect().height`
- `hide()` 中 observer 清理
因此,本轮不需要新增测量机制,只需要让真实测量高度包含新的底部 padding。
## 5. 当前最小可行修复路径
在不改 DOM 结构、不改业务行为前提下,最小修复应为:
1. 保留 pageHeader sticky 和现有 mask。
2. 取消透明 `marginBottom`
3. 改由 `pageHeaderEl` 自身底部 padding 承载 8px 间距。
4. 让 `--ahs-settings-page-header-height` 测量包含这段 padding。
5. 让卡片标题 sticky `top` 仅等于页面标题高度变量,不再额外加 gap。
## 6. 结论
当前真实差距是:
1. 8px 间距仍由透明 margin 提供。
2. 这会让下层文本和内容穿透该区域。
3. 卡片标题 sticky top 仍把 gap 当作 header 外部距离来计算。
4. 现有测量和清理路径已经足够支撑修复。
因此,本轮应把 8px 从透明 margin 收编到 pageHeader 自身 padding 内,使间距属于不透明 header 背景,并让卡片标题直接停靠在测量后的真实 pageHeader 高度下方。

View file

@ -609,7 +609,7 @@ describe("PluginSettingTab", () => {
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
});
it("applies sticky styles to the page header, shared gap, mask, and card headers", () => {
it("applies sticky styles to the opaque page header gap, mask, and card headers", () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -622,7 +622,8 @@ describe("PluginSettingTab", () => {
expect(pageHeader.style.zIndex).toBe("300");
expect(pageHeader.style.width).toBe("100%");
expect(pageHeader.style.boxSizing).toBe("border-box");
expect(pageHeader.style.marginBottom).toBe("var(--ahs-settings-sticky-card-gap, 8px)");
expect(pageHeader.style.marginBottom).toBe("0");
expect(pageHeader.style.paddingBottom).toBe("calc(12px + var(--ahs-settings-sticky-card-gap, 8px))");
expect(pageHeader.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
expect(pageHeader.style.boxShadow).toBe("none");
expect(collectTexts(pageHeader)).toContain("Anki Heading Sync");
@ -640,7 +641,7 @@ describe("PluginSettingTab", () => {
for (const cardId of ["card-types", "sync-content", "scope", "deck", "commands"] as const) {
const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId);
expect(header.style.position).toBe("sticky");
expect(header.style.top).toBe("calc(var(--ahs-settings-page-header-height, 64px) + var(--ahs-settings-sticky-card-gap, 8px))");
expect(header.style.top).toBe("var(--ahs-settings-page-header-height, 64px)");
expect(header.style.zIndex).toBe("200");
expect(header.style.display).toBe("flex");
expect(header.style.width).toBe("100%");

View file

@ -45,6 +45,7 @@ const SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK = `${SETTINGS_PAGE_HEADER_FALLBACK_HE
const SETTINGS_STICKY_CARD_GAP = `${SETTINGS_STICKY_CARD_GAP_PX}px`;
const SETTINGS_PAGE_HEADER_HEIGHT_VALUE = `var(${SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE}, ${SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK})`;
const SETTINGS_STICKY_CARD_GAP_VALUE = `var(${SETTINGS_STICKY_CARD_GAP_VARIABLE}, ${SETTINGS_STICKY_CARD_GAP})`;
const SETTINGS_PAGE_HEADER_PADDING_TOP = "12px";
const SETTINGS_PAGE_HEADER_MASK_TOP = "-128px";
const SETTINGS_PAGE_HEADER_MASK_SIDE = "-24px";
@ -118,8 +119,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
pageHeaderEl.style.zIndex = "300";
pageHeaderEl.style.width = "100%";
pageHeaderEl.style.boxSizing = "border-box";
pageHeaderEl.style.padding = "12px 0";
pageHeaderEl.style.marginBottom = SETTINGS_STICKY_CARD_GAP_VALUE;
pageHeaderEl.style.paddingTop = SETTINGS_PAGE_HEADER_PADDING_TOP;
pageHeaderEl.style.paddingBottom = `calc(${SETTINGS_PAGE_HEADER_PADDING_TOP} + ${SETTINGS_STICKY_CARD_GAP_VALUE})`;
pageHeaderEl.style.marginBottom = "0";
pageHeaderEl.style.overflow = "visible";
pageHeaderEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
pageHeaderEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
@ -171,7 +173,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
headerEl.style.alignItems = "center";
headerEl.style.justifyContent = "flex-start";
headerEl.style.position = "sticky";
headerEl.style.top = `calc(${SETTINGS_PAGE_HEADER_HEIGHT_VALUE} + ${SETTINGS_STICKY_CARD_GAP_VALUE})`;
headerEl.style.top = SETTINGS_PAGE_HEADER_HEIGHT_VALUE;
headerEl.style.zIndex = "200";
headerEl.style.width = "100%";
headerEl.style.maxWidth = "100%";