diff --git a/docs/settings-sticky-opaque-gap-decisions.md b/docs/settings-sticky-opaque-gap-decisions.md new file mode 100644 index 0000000..edc4b4d --- /dev/null +++ b/docs/settings-sticky-opaque-gap-decisions.md @@ -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 本体。 \ No newline at end of file diff --git a/docs/settings-sticky-opaque-gap-gap-report.md b/docs/settings-sticky-opaque-gap-gap-report.md new file mode 100644 index 0000000..de69446 --- /dev/null +++ b/docs/settings-sticky-opaque-gap-gap-report.md @@ -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 高度下方。 \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 9cace1e..f21014f 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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%"); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 39b106b..f8bcae1 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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%";