视频加载失败

导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)

9020 字
45 分钟
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)

导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)#

本文记录 Firefly 博客一次连续的导航栏/卡片 UI 改造:六个已合并提交 + 一条被回滚的实验分支,时间跨度 2026-10-04 至 2026-10-05。写给两类读者:

  • 未来的维护者(包括新会话的 AI 助手):读完应知道每一批改了什么、为什么、当时的验证结论与未尽事项;
  • 希望复现的访客:文末给出两条复现路径——git 一次重放,或按批次手工重做,全部关键代码在正文中可照抄。

〇、概览:最终形态与提交清单#

改造完成后的导航栏最终形态:

  1. 卡片边框体系:开启 enable-card-border 后,全站卡片与导航栏是 2px 纯黑(暗色自动切白)边框,粗细可在显示设置面板用 0–5px 滑块实时调节,持久化到 localStorage。
  2. 悬浮胶囊导航栏:高度从 4.5rem 压扁到 4rem,圆角 9999px,整条导航栏通过 wrapper 的 padding-top: 0.75rem 悬浮于顶部之下,间隙条点击穿透。
  3. 三段独立胶囊 + 下滑收拢动画:导航栏拆为左(logo/站名)、中(菜单)、右(控件)三个独立胶囊;向下滚动超过 80px 时左右两段帘式收起为正圆,中段菜单常显不收;上滑或回顶恢复。
  4. PC 收拢态快捷面板:收拢后右段只剩一个汉堡按钮(PC 端此时才显示),点击弹出「控件快捷面板」——搜索/音乐/播放/显示设置/亮暗五行;点任意行导航栏保持收拢,原控件的子卡片在原位弹出。移动端汉堡仍开导航链接菜单,两菜单互斥。
  5. 移动端菜单样式可配置:siteConfig.nav.mobileMenuStyle 支持 "card"(旧版浮层卡片,默认)与 "drawer"(全屏抽屉)。
  6. PC 搜索图标化:PC 端导航栏不再内嵌搜索输入框,与移动端统一为一个搜索图标;点击弹出搜索卡片,打开后光标自动落入卡内输入框。
  7. 收拢动画卡顿修复:PC 右段收拢成圆不再「弹一下→冻结→猛缩」——汉堡由 display 切换改为 width 连续过渡,控件组 max-width 去掉 30rem 死区;亮暗图标切主题时交叉淡化(第十三节)。
提交日期内容规模
7468034110-04卡片边框默认 2px 纯黑,显示设置新增边框粗细滑块11 文件 +107/−4
2a00a60210-04导航栏改为悬浮胶囊样式并压扁高度4 文件 +33/−25
f6b3d06010-04导航栏接入卡片边框,边框色亮黑暗白跟随主题2 文件 +16/−12
b7cce68210-05三段拆分、收拢动画、移动菜单配置、PC 快捷面板(含”原位弹卡”修复)17 文件 +500/−157
04197b8210-05PC 搜索收成图标按钮,输入统一走搜索面板(第十二节)4 文件 +37/−72
d7d3b62c10-05修复 PC 收拢动画卡顿(第十三节)2 文件 +29/−20

基线提交为 020533ee(其后是 74680341),全部改动可用 git diff 020533ee..d7d3b62c 一次性查看。

另有一次液态玻璃(liquid glass)导航栏原型:做出可运行版本后按要求整体回滚删除(含 index.html 原型与 banner-home.css 改动),未进入任何提交,仅在此备忘——若将来重做,需从零开始,git 历史里没有可恢复的痕迹。


一、环境与验证基线#

  • 技术栈:Astro 7 + Svelte 5 + TypeScript,包管理 pnpm(preinstall 强制),样式为 Tailwind v4 + 分层 CSS(navbar.css、main.css 等以 @import 进主入口,未分层)。
  • 本地开发:pnpm dev(默认 http://localhost:4321,绑 ::1,测试用 localhost 不要用 127.0.0.1)。
  • 检查三件套(每批改完必跑):
Terminal window
npx biome check <改动文件> # 格式/静态检查(只读,不用 pnpm lint——会波及无关文件)
pnpm type-check # tsc --noEmit --isolatedDeclarations
pnpm check # Astro 诊断;基线 = 12 个存量错误(anime.astro 11 + vndb.astro 1)

pnpm check 的验收标准是「仍为 12,无新增」,不是零错误。

  • 验证分工:AI 负责代码层与自动化断言;视觉验收由人完成。自动化用 Playwright(本文第八节列出它在本项目的三个特有坑)。

二、批次一:卡片边框体系(74680341)#

目标#

把「卡片边框」从主题色细分线(--line-divider,1px 半透明)改为可调粗细的纯黑描边,默认 2px,并在显示设置面板提供滑块。

改动明细#

1. src/styles/main.css — 三条边框规则统一接变量:

/* 开启边框和阴影效果时的样式(宽度由 --card-border-width 控制,默认 2px,可在显示设置面板调节) */
.enable-card-border .card-base {
@apply transition-all duration-300 border border-black shadow-xs;
border-width: var(--card-border-width, 2px);
}
/* .card-base-transparent、.btn-card 同理,共三处 */

2. src/utils/setting-utils.ts — 新增四个函数(+51 行):getDefaultCardBorderWidth() 返回 2;getStoredCardBorderWidth() 读 localStorage.cardBorderWidth 并 clamp 到 0–5;applyCardBorderWidthToDocument(width) 写 document.documentElement.style.setProperty("--card-border-width", ...);setCardBorderWidth(width) 持久化 + 应用。

3. src/layouts/Layout.astro — 首屏内联脚本(FOUC 防护)中追加:读 localStorage.cardBorderWidth,校验 0–5 后在解析 HTML 期间就把 --card-border-width 写到 <html> 上,避免滑块值加载后闪一下默认边框。

4. src/components/controls/DisplaySettingsIntegrated.svelte — 「卡片样式」区块新增滑块(+36 行):

let cardBorderWidth = $state(getStoredCardBorderWidth());
// ...
<div class="rounded-md bg-(--btn-regular-bg) p-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium ...">{i18n(I18nKey.cardBorderWidth)}</span>
<span class="text-xs ...">{cardBorderWidth}px</span>
</div>
<input type="range" min={0} max={5} step={0.5} value={cardBorderWidth}
oninput={(e) => { cardBorderWidth = Number(e.currentTarget.value); }}
class="slider w-full overlay-slider" />
</div>

同时接入三处状态机:$effect(() => setCardBorderWidth(cardBorderWidth)) 实时生效;cardSettingsIsDefault 加入宽度默认判断;resetCardSettings() 恢复默认宽度并 requestAnimationFrame(refreshAllRangeProgress) 刷新滑块进度条。

5. i18n — I18nKey.cardBorderWidth 键 + 7 个语言文件(en/ja/ko/ru/zh_CN/zh_TW…)各加一行,中文为「边框粗细」。

复现要点#

按上面 1→5 顺序做即可。注意 clampNumber 是 setting-utils.ts 里已有的工具函数,直接复用;滑块 step={0.5},但正圆公式依赖的 CSS 变量能接受小数。


三、批次二:悬浮胶囊化(2a00a602)#

目标#

导航栏从「贴顶方角条」改为「悬浮圆角胶囊」,并整体压扁一档。

改动明细#

1. 高度 4.5rem → 4rem(三处 + 联动):

  • HeaderTopRow.astro:fixed 态类名 h-18 → h-16;
  • Navbar.astro:容器 h-18 → h-16;
  • layout-base.css:#top-row 的 sticky/fixed 固定高度 4.5rem → 4rem(两处 + 移动端一处),banner 首页占位 calc(var(--banner-height-home) - 4.5rem) → - 4rem。

2. 圆角全模式统一 9999px(navbar.css):bar 本体、#navbar 阴影载体、以及 semi/semifull/亮暗各壁纸态的 !important 圆角覆盖,全部由 0 0 0.75rem 0.75rem 改为 9999px。

3. 悬浮间距放 wrapper:

/* 间距放在 wrapper 的 padding-top 而非 bar 的 margin:
navbar-hidden 的 -translate-y-full 按 wrapper 自身高度平移,
padding 计入高度,下移多少就能完整隐藏多少,不会在顶部残留 12px 边缘。
pointer-events:间隙条是 wrapper 的 padding,整体设为 none 让点击穿透到下层,
bar 自身设回 auto(未分层选择器优先于 Tailwind 工具类) */
#navbar-wrapper {
padding-top: 0.75rem;
pointer-events: none;
}

配套给 bar(#navbar>div)加 pointer-events: auto——否则悬浮间隙条会把点击吃掉,导航栏上的按钮和下拉面板全部点不到。这两行是一对,必须同时改。

4. 移动端成形(<768px):组件里 top-row 是 px-0,bar 会贴屏幕边缘,胶囊两端没留空就不成形——删掉旧的「移动端去圆角」规则,改为给 #top-row 补 padding-left/right: 0.75rem。

为什么间距不能用 margin#

见上面代码注释:-translate-y-full 的隐藏动画按 wrapper 自身高度平移,margin 不计入 wrapper 高度,会残留一条边;padding 计入,隐藏才干净。


四、批次三:亮黑暗白边框(f6b3d060)#

目标#

纯黑边框在暗色主题下不可见,需要亮色纯黑、暗色纯白地跟随主题,并让导航栏与卡片共用同一套边框变量。

改动明细#

1. src/styles/variables.styl — 亮暗两段各加一个变量:

/* 亮色段 */
--card-border-color: black
/* 暗色段 */
--card-border-color: white

2. src/styles/main.css — 三处卡片规则 border-black 改 border-(--card-border-color);导航栏规则改为:

.enable-card-border #navbar > div:not(.absolute) {
@apply transition-all duration-300 border shadow-xs;
/* 与卡片边框同款:颜色跟随 --card-border-color(亮黑/暗白),宽度跟随 --card-border-width 滑块
(!important 压过 navbar.css 的未分层 1px 透明边框) */
border-color: var(--card-border-color) !important;
border-width: var(--card-border-width, 2px) !important;
}

3. 删除两组「防闪现」旧规则:原实现里 banner 玻璃态与 fullscreen 模式各有 border: none !important,目的是防止切页瞬间边框从无到有闪一下。边框改为刻意设计的常驻元素后,「闪现」的定义变了——常驻即无闪现,过渡期由 .is-wallpaper-transitioning 的透明边框规则兜底。因此删除 banner 的 border: none 组,fullscreen 只保留 outline: none。删除行全部有意,复现时不要当作误删恢复。

本批次踩坑:Vite dev 编译缓存丢更新#

现象:改了 variables.styl 暗色白值 + main.css 导航栏引用,磁盘文件正确,浏览器里暗色边框仍是黑的,刷新无效。

根因:dev server 同一轮里各写入了两次文件,Vite watcher 只吃到了每个文件的第一次写入,第二次的失效事件丢失——served 编译产物残缺。

处置:重存文件踢一脚强制重编译(或重启 pnpm dev)。诊断手段:curl http://localhost:4321/src/styles/xxx 直接取模块编译产物,对比磁盘源码。

结论:纯本地 dev 现象。Cloudflare 构建从磁盘全新编译,线上不复现。以后遇到「CSS 改了不生效/只生效一半」,先重启 pnpm dev。


五、批次四:三段拆分与下滑收拢动画(b7cce682 上半)#

5.1 结构:三段独立胶囊#

src/components/layout/Navbar.astro:

  • #navbar 改为 grid grid-cols-[1fr_auto_1fr](类名里保留 z-50 等),三个直接子 div 各带 .navbar-seg:
    • .navbar-seg-left(justify-self-start):logo + 站名,站名包一层 <span class="navbar-title-text">;
    • .navbar-seg-center(col-start-2,hidden lg:flex):菜单 DropdownMenu 列表,是否居中由 siteConfig.nav.menuAlign 决定;
    • .navbar-seg-right(col-start-3 justify-self-end):内含 .navbar-right-controls 控件组(搜索、音乐、播放、显示设置、亮暗 + 各浮层面板)与组外的汉堡按钮 #nav-menu-switch。
  • 原先「面板挂在 body 级 / 控件散放」的结构改为面板随控件进右段(音乐面板等成为控件组子元素)。

src/styles/navbar.css:

  • 全部原 #navbar>div 表面规则(背景、透明边框、毛玻璃 ::before)retarget 为 #navbar>.navbar-seg,并在其上补一行注释掉的 position: relative——它同时是内部浮层面板的定位包含块(后文第八节第 3 条会再用到);
  • sticky 滚动投影从元素 box-shadow 移到 ::after 伪元素(各壁纸态规则会覆盖元素级阴影,伪元素不参与那场竞争);
  • 移动端 top-row 补左右留边(承接批次二第 4 条)。

src/styles/main.css:31 处选择器从 >div retarget 到 >.navbar-seg;边框规则同步接 --card-border-color / --card-border-width。

src/styles/layout-base.css:top-row 高度相关 calc 基准同步为 4rem。

5.2 动画:下滑收拢为正圆#

触发(src/utils/scroll-utils.ts)——把滚动方向 delta 从 dynamic 分支上提为共用计算:

const delta = scrollTop - lastScrollTop;
lastScrollTop = scrollTop;
// 三段胶囊收拢:向下滚动且超过 80px 时左右段收成圆形,上滑或回顶恢复
if (navbarElement) {
operations.push(() => {
navbarElement.classList.toggle("navbar-collapsed", scrollTop > 80 && delta > 0);
});
}

语义:收拢 = 向下滚且 >80px;恢复 = 上滑或回顶。中段菜单永不参与。

切页清态(src/utils/swup-transitions.ts):astro 换页钩子里 navbar.classList.remove("navbar-collapsed")——收拢态不跨页残留,新页滚动逻辑按新滚动位置重新判断。

形变规则(src/styles/navbar.css):

/* 站名帘式收起 */
#navbar.navbar-collapsed .navbar-seg-left .navbar-title-text {
max-width: 0;
opacity: 0;
}
/* logo 外边距归零(图标盒固定 1.75rem,保证两种 logo 形态收拢宽度一致) */
#navbar.navbar-collapsed .navbar-seg-left .navbar-logo { margin-right: 0; }
/* 左右段内边距收成正圆所需的值 */
#navbar.navbar-collapsed .navbar-seg-left {
padding-left: calc(1.125rem - var(--card-border-width, 2px));
padding-right: calc(1.125rem - var(--card-border-width, 2px));
}
.navbar-right-controls { max-width: 30rem; overflow: hidden; /* ...过渡见批次六 */ }
#navbar.navbar-collapsed .navbar-right-controls { max-width: 0; }
#navbar.navbar-collapsed .navbar-seg-right {
padding-left: calc(0.625rem - var(--card-border-width, 2px));
padding-right: calc(0.625rem - var(--card-border-width, 2px));
}

正圆公式推导(box-sizing: border-box,高度 4rem 含边框):

宽度 = padding-left + 内容 + padding-right + 2×边框宽 ≡ 高度 4rem
左段内容 = logo 图标盒 1.75rem
→ padding = (4rem − 2×边框 − 1.75rem) / 2 = 1.125rem − 边框宽
右段内容 = 汉堡按钮 2.75rem
→ padding = (4rem − 2×边框 − 2.75rem) / 2 = 0.625rem − 边框宽

两个注意点:边框宽取变量 --card-border-width,用户用滑块调粗后圆依然精确;rem 在 <768px 是 14px(根元素 text-[14px] md:text-[16px]),写死 px 会在移动端错位——所以全部用 rem + calc。

汉堡按钮显隐(PC 专用):

/* 移动端常显;≥1024px 仅收拢态显示 */
@media (min-width: 1024px) {
#navbar:not(.navbar-collapsed) .navbar-seg-right #nav-menu-switch {
display: none;
}
}

这里有个关键决策:最初把 lg:hidden! 写在按钮的类名上,结果 Tailwind 分层内 important 在层序反转下必胜(utilities 层的 !important 压过未分层的普通规则),收拢态想让汉堡出现的覆盖规则永远赢不了——表现为 PC 收拢后右段无图标、只剩细条、点不到。解法是放弃 important 角力:按钮类名去掉 lg:hidden!,显隐完全交给上面这条普通规则,由特异性与层序自然接管。

5.3 本批次的行为语义(验收用)#

  • 向下滚(>80px):左段收成圆(logo 存活、站名消失)、右段收成圆(只剩汉堡)、中段菜单纹丝不动;
  • 上滑或回顶:全部恢复;
  • 切页:立即恢复完整态;
  • PC 非收拢态:汉堡隐藏;PC 收拢态:汉堡显示;移动端:汉堡始终显示。

六、批次五:移动端菜单样式可配置(b7cce682 一部分)#

目标#

上游主题把移动端汉堡菜单重构成了全屏抽屉(MD3 侧滑 + 遮罩),但旧版「锚定导航栏下方右侧的圆角卡片」更贴合本博客的胶囊风格——做成配置项,两种都保留。

改动明细#

1. 配置项(src/config/siteConfig.ts + src/types/siteConfig.ts):

nav: {
// 移动端汉堡菜单样式:"card" 浮层卡片(旧版)/"drawer" 全屏抽屉(上游重构版)
mobileMenuStyle: "card",
// 类型:mobileMenuStyle?: "card" | "drawer";
}

2. src/components/layout/NavMenuPanel.astro 双分支渲染:

  • card 分支:复刻历史版本(git show a65c6576^:src/components/layout/NavMenuPanel.astro 可拿到原貌),根类带 nav-menu--card 标记,样式为 fixed right-4 top-21 圆角浮层卡片、max-h-[80vh] overflow-y-auto;
  • drawer 分支:保留全屏 fixed inset-0 抽屉,所有抽屉专属样式规则加 .nav-menu--drawer 作用域,避免两个分支互相污染。

3. 关闭机制(src/utils/layout-init.ts):卡片模式依赖 click-outside 关闭;抽屉模式面板全屏覆盖、点击恒在面板内、该监听永不触发——两种模式都注册注册即可,无副作用。代码注释里写明了这一点。

切换方法#

改 siteConfig.ts 的 mobileMenuStyle 为 "drawer" 重启 dev 即可切到抽屉版。


七、批次六:PC 收拢态快捷面板与「原位弹卡」修复(b7cce682 下半)#

这是整个系列里迭代轮次最多的一块,经历了「实现 → 验收发现逻辑不对 → 定位两处根因 → 修复」的完整过程,值得完整记录。

7.1 需求#

收拢态下 PC 端右段只剩汉堡(中段菜单已常显导航链接,所以汉堡不该再开链接菜单),期望:

  1. 汉堡点击打开**「右段被隐藏控件」的快捷面板**——搜索/音乐/播放/显示设置/亮暗五行;
  2. 点击任意行,导航栏保持收拢,弹出「原来点击控件时会弹出的那张子卡片」(搜索面板、音乐面板、显示设置、亮暗菜单);
  3. 移动端行为不变:汉堡开导航链接菜单;两菜单互斥。

7.2 快捷面板本体#

Navbar.astro 右段新增:

<div id="navbar-quick-panel"
class="float-panel float-panel-closed absolute top-full right-0 mt-2 w-max min-w-52 p-1.5 z-50"
data-floating-panel data-floating-panel-trigger="nav-menu-switch"
inert aria-hidden="true">
<button data-quick-action="search">…</button> <!-- 音乐/播放/显示/亮暗同构,按配置条件渲染 -->
</div>

要点:w-max 必不可少——面板锚在收拢后只有约 64px 宽的右段里,绝对定位的收缩宽度会被包含块钳制,没有 w-max 就会被压扁;data-floating-panel 让它接入统一的 inert/Esc 管理。

分流逻辑(loadButtonScript 内汉堡 onclick):

let isDesktop = window.matchMedia("(min-width: 1024px)").matches;
if (isDesktop && quickPanel) {
quickPanel.classList.toggle("float-panel-closed");
navPanel && navPanel.classList.add("float-panel-closed"); // 互斥
} else if (navPanel) {
navPanel.classList.toggle("float-panel-closed");
quickPanel && quickPanel.classList.add("float-panel-closed");
}

7.3 第一版的 bug 与两处根因#

现象:在快捷面板里逐个点搜索/音乐/显示设置/亮暗——导航栏重新展开,子卡片却不弹出。

根因一(展开):行点击处理器里有一句 navbar.classList.remove("navbar-collapsed")。它的本意是「控件组收拢时被 opacity: 0 藏住了,展开才能看见子卡」——方向就错了,需求是保持收拢。

根因二(子卡秒关):layout-init.ts 用 setClickOutsideToClose 给每个面板注册了 document 级点击关闭监听,它注册得比快捷面板的行点击处理器更晚。DOM 事件按注册顺序派发,于是同一个点击事件里:

行点击处理器:打开子卡 ✓
→ 后注册的 click-outside 监听器继续跑:点击目标(快捷面板的行)在子卡之外 → 把刚打开的子卡关掉 ✗

两口锅叠加,用户看到的就是「只展开了导航栏,什么卡都没弹」。

修复(Navbar.astro 行点击处理器重写):

document.addEventListener("click", function (event) {
if (!(event.target instanceof Element)) return;
const row = event.target.closest("#navbar-quick-panel [data-quick-action]");
if (!row) return;
event.stopImmediatePropagation(); // ← 挡掉注册更晚的 document click-outside,防同事件秒关
const action = row.getAttribute("data-quick-action");
const triggerByAction = { search: "search-switch", music: "music-player-switch",
video: "bg-player-toggle", display: "display-settings-switch",
theme: "scheme-switch" };
// 有子卡的行:关快捷卡、换弹子卡;video 只切换播放没有子卡,快捷卡留着当状态面板
if (action !== "video") {
document.getElementById("navbar-quick-panel")?.classList.add("float-panel-closed");
}
document.getElementById(triggerByAction[action])?.click(); // 程序化点击原控件按钮
// 搜索子卡在桌面端复用面板内输入框(见 7.5);inert 在微任务里解除,焦点延到宏任务再给
if (action === "search") {
setTimeout(() => {
document.querySelector("#search-bar-inside input")?.focus();
}, 0);
}
});

stopImmediatePropagation 的安全性论证(复现时按此检查):Navbar.astro 自带的面板关闭监听注册在本处理器之前,此刻子卡尚未打开、必为空操作;layout-init 的监听注册在之后,被挡掉正是目的;两者的忽略列表本就不依赖这次事件。无论两侧注册顺序如何翻转,结论都成立。

7.4 收拢态子卡可见性::has 守卫#

子卡(搜索/音乐/显示/亮暗面板)是控件组 .navbar-right-controls 的后代,而收拢规则给控件组加了 opacity: 0——opacity 作用于整棵子树,绝对定位的子卡也会一起隐身。第一版「展开导航栏」的错误修复就是在绕这个问题。

正确解法是把「形变」与「淡出」拆成两条规则,淡出加守卫(navbar.css):

/* 形变:始终收拢——flow 子元素(按钮们)被 max-width:0 + overflow:hidden 裁掉 */
#navbar.navbar-collapsed .navbar-right-controls {
max-width: 0;
}
/* 淡出:仅在没有任何子浮层面板打开时启用 */
#navbar.navbar-collapsed:not(:has(.navbar-right-controls .float-panel:not(.float-panel-closed))) .navbar-right-controls {
opacity: 0;
}

两个前提成立才有效,复现时缺一不可:

  • max-width: 0 裁不到子卡:子卡是绝对定位,包含块是 position: relative 的 .navbar-seg(不是静态的控件组),静态祖先的 overflow: hidden 不裁切「包含块在其外部」的绝对定位后代;
  • :has 状态即时:float-panel-closed 类名一变,:has 匹配结果同步翻转(已实测:class 移除当帧 matches() 即为 false),无需额外 JS 状态同步。

附带收益:「子卡开着时再下滚,面板随控件组一起被藏起」这个此前登记在案的边缘情况被一并修掉了——现在子卡开着时收拢,守卫命中、控件组不淡出,面板保持可见可交互。

7.5 搜索子卡在桌面端的输入问题#

PC 正常态的搜索输入在导航栏内的 #search-bar(hidden lg:flex),收拢后被帘式收起;而搜索面板里的 #search-bar-inside 是给移动端的(lg:hidden)。若不处理,PC 点搜索行会弹出一张没有输入框的空卡。

修复(navbar.css):

/* 收拢态从快捷面板唤起搜索时,面板内给移动端用的输入框顶上 */
#navbar.navbar-collapsed #search-bar-inside {
display: flex;
}

未分层规则对 Tailwind utilities 层的普通 lg:hidden 自然占优,不需要 important。加上 7.2 里 setTimeout 自动聚焦,PC 收拢态点搜索 = 面板弹出、光标已就位、直接打字出结果(dev 环境 Pagefind 走 mock 数据,npm run build && npm preview 才能测真搜索)。回顶恢复后该规则因 :not(.navbar-collapsed) 失效,输入框回到 lg:hidden,无残留。

注意:本节方案已被第十二节取代(2026-10-05 追加批次):PC 内嵌的 #search-bar 已整体删除,#search-bar-inside 去掉 lg:hidden 全端常驻——本节的收拢态补丁规则随之变成死规则、已删。历史过程保留在此供参考,复现以第十二节为准。

7.6 video 行的误关与忽略名单#

video 行没有子卡,它程序化 .click() 的是 #bg-player-toggle——这个合成 click 会冒泡到 document,被快捷面板自己的 click-outside 监听判定为「点外面」,把快捷面板关了(卡面板行无所谓,反正主动关;video 行就变成了「点一下菜单自己消失」)。

修复(src/utils/layout-init.ts)——把五个控件触发按钮全部加进快捷面板的忽略名单:

setClickOutsideToClose("navbar-quick-panel", [
"navbar-quick-panel",
"nav-menu-switch",
"search-switch",
"music-player-switch",
"bg-player-toggle",
"display-settings-switch",
"scheme-switch",
]);

理由写进了注释:这些按钮的程序化点击不算「点外面」。真实用户点不到它们(收拢态下被裁切),所以不影响任何正常交互。

7.7 配套 i18n#

快捷面板两个新行名需要文案:I18nKey.displaySettings(显示设置)、I18nKey.lightDarkMode(亮暗模式),7 个语言文件各加两行。

7.8 本批次最终行为矩阵(自动化实测通过)#

操作期望结果
PC 下滚收拢 → 点汉堡快捷面板弹开,导航链接菜单关闭(互斥)
点搜索行导航栏保持收拢;快捷卡关;搜索卡开、inert 解除、焦点在面板输入框;控件组 max-width: 0 但 opacity: 1
点音乐/显示/亮暗行同上(子卡各自弹出),导航栏保持收拢
点 video 行快捷卡保持打开(无子卡),播放状态切换
子卡打开时点外部子卡关,控件组 opacity 回 0,导航栏仍收拢
快捷卡打开时点外部快捷卡关
回顶导航栏恢复完整、汉堡隐藏、面板内输入框回 lg:hidden
移动端点汉堡开导航链接菜单,快捷卡不开;再点关闭

八、跨批次踩坑合集#

改造过程中沉淀的九条硬结论,单独成节,任何一条都值得后来者抄走:

1. Tailwind 分层与 important 的层序反转。 本项目 navbar.css 等样式文件未分层,Tailwind utilities 在 @layer utilities 里。普通规则未分层胜分层;但 utilities 里的 !important 反转层序、必胜未分层的普通覆盖。想覆盖 lg:hidden! 这类带 bang 的工具类,靠写更狠的 important 是死路——把 bang 从类名上删掉,改用一条普通规则才是正解(汉堡显隐即是案例)。

2. border-box 下的正圆公式。 高度含边框时,宽度分量必须把边框计入再对半分,且内边距要写成 calc(半径差 - var(--card-border-width)),滑块调粗边框圆才不破。rem 别写死 px:根字号在移动端是 14px。

3. 绝对定位子卡的包含块是 .navbar-seg,不是控件组。 position: relative 挂在段上(且段有 overflow-visible!),静态控件组的 overflow: hidden / max-width: 0 裁不到子卡——这是「收拢态弹子卡」方案能成立的几何前提。改结构时若把 position: relative 挪走或给段加上 overflow,整套都会塌。

4. opacity 作用于整棵子树,无法被后代覆盖。 「祖先把 opacity 设 0、子卡单独显示」在 CSS 里无解,只能拆规则(:has 守卫)或挪 DOM。同理 visibility 可以被后代重写、opacity 不行——别选错属性。

5. Vite dev 编译缓存会丢同轮多次写入。 症状:磁盘正确、served 残缺。处置:重启 pnpm dev;诊断:curl 模块产物对比源码。线上构建从磁盘全新编译,不受影响。

6. Playwright headless 的 evaluate 期间不泵帧。 长时间驻留在 page.evaluate 的 async 代码里做 setTimeout 采样,CSS 过渡的 currentTime 会冻结在 0、getComputedStyle 读到旧值——全部是探针自伤,不是页面 bug。本次曾因此误判 :has 守卫失效,排查了数轮。正确姿势:用 page.waitForFunction(客户端 rAF 轮询会泵帧)等过渡完成,或读数前 document.getAnimations().forEach(a => a.finish()) 强制取目标值,或穿插 page.screenshot 触发渲染。判定标准:getAnimations() 里 playState: "running" 但 currentTime 恒为 0,即冻结而非停滞。

7. floating-panel-utils 的分工(src/utils/floating-panel-utils.ts)。 data-floating-panel-trigger 只做 aria/inert/焦点同步,不绑定点击(多面板共享同一 trigger 安全);面板 class 变化由 MutationObserver 同步 inert 并在开→关时派发 floating-panel:close(不冒泡,要在 capture 阶段或直接挂面板上监听);setClickOutsideToClose(panel, ignores) 是无条件的 document 点击监听——不检查面板是否打开,直接加 float-panel-closed;忽略列表按 id 做 DOM 包含判断,与面板开闭状态无关。理解这三条才能推演「同事件内先开后关」类时序 bug。

8. swup 4 事件。 可靠的是 astro:before-swap / astro:page-load;swup:contentReplaced 是死事件(NavMenuPanel.astro 里有一处历史遗留监听,本次未动,改事件前先处理它)。

9. 工具纪律。 pnpm lint(Biome safe-fix)会波及无关文件,本系列全程只用只读的 npx biome check <files>;Playwright 的 element screenshot 会被动画卡 stability,交互诊断用 run_code_unsafe 里 page.setViewportSize + evaluate。


九、如何复现#

路径 A:git 一次重放(推荐)#

Terminal window
git clone https://github.com/jmqsOOOtatoba/FireflyPriWeb.git
cd FireflyPriWeb
git checkout 020533ee # 系列起点(最后一批之前的基线)
git cherry-pick 74680341 2a00a602 f6b3d060 b7cce682 04197b82 d7d3b62c
# 若目标仓库没有这几个对象,先从原仓库 fetch,或直接:
# git diff 020533ee..d7d3b62c | git apply
pnpm install
pnpm dev # http://localhost:4321

注:b7cce682 依赖 74680341 引入的 --card-border-width 与 f6b3d060 的 --card-border-color,顺序不能乱;04197b82(搜索图标化)依赖 b7cce682 的三段结构;d7d3b62c(卡顿修复)建立在 b7cce682 的收拢动画之上,放最后。9e8a7908(18 文章补 author 字段)与本系列无关,可跳过。

路径 B:按批次手工重做#

按本文第二、三、四、五、六、七节的「改动明细」顺序执行,每节末尾的复现要点即 checklist:

  1. 边框体系(第二节):main.css 三规则 + setting-utils.ts 四函数 + Layout.astro 内联应用 + 显示设置滑块 + i18n;
  2. 悬浮胶囊(第三节):高度 4rem 三处联动 + 9999px 圆角 + wrapper padding/pointer-events 对 + 移动端留边;
  3. 亮黑暗白(第四节):--card-border-color 变量 + 导航栏 !important 接管 + 删两组防闪现规则;
  4. 三段与收拢(第五节):Navbar 结构拆分 + navbar.css/main.css retarget + scroll-utils delta 上提 + 正圆公式 + 汉堡显隐普通规则;
  5. 移动菜单配置(第六节):mobileMenuStyle 配置 + NavMenuPanel 双分支 + click-outside 双注册;
  6. 快捷面板(第七节):quick panel markup + 分流 + 行点击处理器(含 stopImmediatePropagation)+ :has 守卫拆规则 + 搜索输入框放开(该补丁后被第十二节取代)+ 忽略名单 + i18n 两键;
  7. 搜索图标化(第十二节):删 PC 内嵌 #search-bar + 图标按钮去 lg:hidden! + 面板输入框去 lg:hidden + 双关键词合并 + togglePanel PC 自动聚焦 + 删收拢态补丁与死忽略项;
  8. 收拢卡顿修复(第十三节):汉堡 display 切换改 width/visibility 连续过渡 + 控件组 max-width 30rem→14rem 去死区 + 删淡入 keyframes + 亮暗图标交叉淡化。

复现后验收清单#

Terminal window
npx biome check src/ # 0 错
pnpm type-check # 0 错
pnpm check # 12 个存量错误(anime.astro 11 + vndb.astro 1),无新增

浏览器人工验收(顺序即第七节 7.8 矩阵):

  1. PC 下滚 >80px → 左右段收成正圆、中段不动、汉堡出现;
  2. 点汉堡 → 快捷卡弹开、链接菜单不开;
  3. 逐行点搜索/音乐/显示/亮暗 → 导航栏不展开、对应子卡原位弹出、搜索卡光标已就位;
  4. 点外部/Esc → 子卡关、仍收拢;再点外部 → 快捷卡关;
  5. video 行 → 快捷卡不关、背景播放切换;
  6. 回顶 → 全部恢复、汉堡消失(含第十二节时:面板内输入框全端常驻,不再有 lg:hidden 切换);
  7. 移动端(<1024px)→ 汉堡开链接菜单、与快捷卡互斥;
  8. 显示设置调边框粗细滑块 → 卡片与导航栏边框实时变粗细,刷新后保持;切暗色 → 边框变白;
  9. 含第十二节时:PC 正常态右段为纯图标排布,点搜索图标 → 卡片弹出、光标自动就位。

十、最终验证记录(2026-10-05)#

检查项结果
npx biome check(改动文件)通过,无 fix
pnpm type-check通过
pnpm check12 errors(= 基线,无新增)
Playwright 行为断言(PC 收拢/五行/外部点击/video/回顶/移动端分流)全部通过
视觉验收由人工完成,验收通过

自动化断言里踩到的探针陷阱见第八节第 6 条——先证明探针可信,再相信断言。


十一、提交记录#

9e8a7908 更新18文章作者 (与本系列无关)
d7d3b62c fix: 修复PC收拢动画卡顿,汉堡改width连续过渡,控件组max-width去死区
04197b82 feat: PC端搜索收成图标按钮,输入统一走搜索面板
b7cce682 feat: PC收拢态汉堡改开控件快捷面板,行点击原位弹出子控件卡
f6b3d060 feat: 导航栏接入卡片边框,边框色改为亮黑暗白跟随主题
2a00a602 feat: 导航栏改为悬浮胶囊样式并压扁高度
74680341 feat: 卡片边框默认 2px 纯黑,显示设置新增边框粗细滑块
020533ee 更新公告动态 (系列起点)

提交粒度说明:前三个是独立小批次、逐批验收逐批提交;b7cce682 因三段结构、收拢动画、菜单配置、快捷面板四个特性在同一批文件里交织(Navbar.astro 一个文件承载三者),拆分提交的手术成本高于收益,故合为一笔、用 commit body 分条说明。


十二、追加批次:PC 搜索收成图标按钮(04197b82,2026-10-05)#

目标#

PC 端右段的内嵌搜索输入框(#search-bar,常态宽 10rem、聚焦展开到 15rem)太占地方,改为与移动端一致:只留一个搜索图标,点击弹出搜索卡片,输入统一在卡内完成。

改动明细(4 文件 +37/−72)#

1. src/components/controls/Search.svelte(主体):

  • 删除 PC 内嵌输入框 #search-bar(连带 #search-input-desktop、handleDesktopFocus、focus-return 钩子);
  • #search-switch 图标按钮去掉 lg:hidden!——注意这是第八节第 1 条的正解:删掉 bang 让显示自然接管,不是写更狠的 important 去压;
  • 面板内输入框 #search-bar-inside 去掉 lg:hidden,桌面/移动共用;
  • keywordDesktop/keywordMobile 双关键词状态合并为单个 keyword(桌面输入框没了,双状态就是死代码),setPanelVisibility/search 同步去掉 isDesktop 参数;
  • data-floating-panel-trigger 从 "search-switch search-input-desktop" 收窄为 "search-switch";
  • togglePanel 打开面板时若视口 ≥1024px,setTimeout(0) 把焦点送进卡内输入框(inert 由 MutationObserver 在微任务解除,焦点必须延到宏任务——与 7.2 同理);移动端刻意不自动聚焦,避免一点开就弹键盘。

2. src/styles/navbar.css:删除 7.5 的收拢态补丁 #navbar.navbar-collapsed #search-bar-inside { display: flex; }——输入框全端常驻后它成了死规则。

3. src/utils/layout-init.ts:search-panel 的 click-outside 忽略名单删掉已不存在的 "search-bar"。

4. src/components/layout/Navbar.astro:只更新快捷面板搜索行的过时注释(不再有「收拢态放开 lg」这回事)。

为什么面板落位不用改#

搜索面板的定位规则(navbar.css 的 --navbar-panel-inset、<768px 贴右段右缘)本来就同时服务桌面与移动——PC 旧方案里输入框虽在导航栏内,面板弹出位置也是同一套规则。图标化后直接复用,实测 1440px 视口面板 left≈861 / right≈1341,正好挂在右胶囊下方不出屏。

验证(2026-10-05,Playwright MCP 实测)#

检查项结果
Biome / type-check / pnpm check通过 / 通过 / 12 errors(= 基线无新增)
PC:#search-bar 不复存在、图标可见、面板默认关✓
PC 点图标 → 面板开、inert 解除、光标就位、打字出 mock 结果✓
PC 点外部 → 面板关✓
下滚收拢 → 汉堡 → 快捷面板搜索行 → 保持收拢、子卡原位弹出、光标就位✓
移动端回归:图标开卡、不自动聚焦、汉堡开链接菜单✓
视觉截图:右胶囊纯图标排布、面板不出屏✓

复现:检出 b7cce682 后按上面四条改动明细重做即可,验收按表格逐行过。


十三、追加批次:PC 收拢动画卡顿修复(d7d3b62c,2026-10-05)#

现象与最初假设#

PC 端下滚触发右段收拢成圆时,肉眼可见一次「卡顿」;同一套动画在移动端却顺滑。最初的假设是:右端图标从亮暗切换变成汉堡、两图标形状不同,切换瞬间没有过渡动画,想用 anime.js 做 SVG 路径变形。

假设被逐帧数据否了——收拢过程根本不触发太阳/月亮互换(那只发生在切主题时),且 moon 与 hamburger 的 SVG path 不同构,anime.js(v3)做不了任意路径变形。真凶在布局层。

诊断:逐帧实测抓到两条根因#

探针:页面内 rAF 采样右段/控件组/汉堡三元素的 getBoundingClientRect().width(先 page.bringToFront()——窗口遮挡时 rAF 掉到 1fps,见第八节第 6 条同源教训)。

修复前曲线(收拢 class 挂上在 t=165ms):

t=165 汉堡 display:none→flex 单帧出现:seg +44px ←—— 反向弹出(PC 独有)
t=165+ 控件组 max-width 从 480px 才开始降,内容 220px
在被钳到 224px 之前宽度纹丝不动:冻结 ~60ms ←—— 空转死区
t=236+ 220px 全挤进剩余时长:单帧 -57、-26、-22px 猛缩 ←—— 卡顿感主体
  1. 汉堡 display 单帧切换:非收拢态 PC 汉堡 display: none 不占位,收拢 class 一挂立刻 +2.75rem,右胶囊首帧向外弹。移动端汉堡常显、宽度不变——与「移动端没问题」的观察完全吻合。
  2. .navbar-right-controls 的 max-width: 30rem 死区:实际内容只有最多 5 个 2.75rem 控件 = 220px,过渡前段(480→224px)对用宽毫无影响,动画空转后余量猛缩,速度曲线呈「冻结→暴冲→拖尾」。

改动明细(2 文件 +29/−20)#

1. src/styles/navbar.css(历史注释分「一改 important」「二改卡顿」两段保留):

/* 汉堡:display:none → width/visibility 连续过渡 */
.navbar-seg-right #nav-menu-switch {
overflow: hidden;
transition:
width 0.36s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.25s ease,
visibility 0.36s;
}
@media (min-width: 1024px) {
#navbar:not(.navbar-collapsed) .navbar-seg-right #nav-menu-switch {
width: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}

三个要点:未分层 width: 0 自然压过 utilities 层的 w-11(层序反转只对 !important 生效,这里不需要 bang);visibility 随行过渡(宽度播完才真正隐藏),非收拢态不进 Tab 焦点与 a11y 树;正圆公式不受影响——收拢态媒体查询不生效,内容宽仍是 w-11 = 2.75rem。

  • .navbar-right-controls 的 max-width: 30rem → 14rem(13.75rem 实际内容 + 0.25rem 余量),死区归零;注释里写明了原值的空转机制。
  • 删除 navbar-collapse-fade 淡入动画与 keyframes:opacity 已由 transition 接管;该动画还会让移动端常显的汉堡每次收拢从 0 重闪一遍(历史遗留瑕疵,一并消灭)。

2. src/components/controls/LightDarkSwitch.svelte:太阳/月亮两个图标容器加 transition-opacity duration-300——这是「图标切换没过渡」假设里唯一真实存在的问题(切主题时 class:opacity-0 硬切),与收拢无关,顺手修掉。

修复后曲线(同探针复测)#

控件组 ctr: 220 → 171 → 107 → 63 → 36 → 20 → … → 0 单调平滑减速
汉堡 ham: 0 → 10 → 23 → 32 → 37 → 40 → … → 44 从零长出、同步交接
终态 seg = 64px 正圆;首帧无反向弹出、无冻结

验证(2026-10-05,Playwright MCP 实测)#

检查项结果
Biome / type-check / pnpm check通过 / 通过 / 12 errors(= 基线无新增)
收拢首帧无 +44px 跳变、无冻结✓(逐帧差分只剩单调递减的正常减速帧)
展开回顶:汉堡 width 0 + visibility hidden、右段回 236px✓
移动端:汉堡常显(38.5px = 14px 根 × 2.75rem)、收拢不闪、点击开菜单✓
视觉验收由人工完成,验收通过

复现:检出 04197b82 后按改动明细重做。rAF 逐帧采样 + bringToFront 这套诊断法可复用于任何「肉眼卡顿但说不清」的动画排查——先量化,再动手。


结语与未尽事项#

做完的:边框体系、悬浮胶囊、三段收拢、快捷面板与原位弹卡、PC 搜索图标化、收拢动画卡顿修复,全部合入主干并上线(推送即触发 Cloudflare 构建)。

未尽与备忘:

  • 液态玻璃原型已回滚,git 无痕,重做需从零;
  • swup:contentReplaced 死事件遗留一处(NavMenuPanel.astro),本次刻意未动;
  • 流程教训:批次验收通过前不提交推送、一次性授权不跨批次沿用——最后一笔 b7cce682 是在验收确认前推的,虽未返工,但顺序不对,下不为例。

对后来者最重要的一句话:这次所有「看起来是页面 bug」的问题里,只有两处是真 bug(同事件 click-outside 秒关、合成 click 误关快捷卡),其余怪现象全部来自探针与 dev 缓存——先怀疑测量工具,再怀疑自己的代码。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
https://mstzuomu.space/posts/azuma.zuomu-blog-19/
作者
左沐
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
陌殊途左沐
热爱是拯救无趣人生的唯一途径
公告
欢迎来到我的博客!这里是左沐的个人空间,分享我的学习、生活和兴趣爱好。希望你能在这里找到有趣的内容,请不要对我的喜好做出评价哦!请勿使用公网访问本站。
分类
访客信息
加载中...
标签
最新动态
站点统计
文章
27
分类
2
标签
47
总字数
48,093
运行时长
0 天
最后活动
0 天前
总浏览量
-
访客数
-
1
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
〇、概览:最终形态与提交清单
一、环境与验证基线
二、批次一:卡片边框体系(74680341)
目标
改动明细
复现要点
三、批次二:悬浮胶囊化(2a00a602)
目标
改动明细
为什么间距不能用 margin
四、批次三:亮黑暗白边框(f6b3d060)
目标
改动明细
本批次踩坑:Vite dev 编译缓存丢更新
五、批次四:三段拆分与下滑收拢动画(b7cce682 上半)
5.1 结构:三段独立胶囊
5.2 动画:下滑收拢为正圆
5.3 本批次的行为语义(验收用)
六、批次五:移动端菜单样式可配置(b7cce682 一部分)
目标
改动明细
切换方法
七、批次六:PC 收拢态快捷面板与「原位弹卡」修复(b7cce682 下半)
7.1 需求
7.2 快捷面板本体
7.3 第一版的 bug 与两处根因
7.4 收拢态子卡可见性::has 守卫
7.5 搜索子卡在桌面端的输入问题
7.6 video 行的误关与忽略名单
7.7 配套 i18n
7.8 本批次最终行为矩阵(自动化实测通过)
八、跨批次踩坑合集
九、如何复现
路径 A:git 一次重放(推荐)
路径 B:按批次手工重做
复现后验收清单
十、最终验证记录(2026-10-05)
十一、提交记录
十二、追加批次:PC 搜索收成图标按钮(04197b82,2026-10-05)
目标
改动明细(4 文件 +37/−72)
为什么面板落位不用改
验证(2026-10-05,Playwright MCP 实测)
十三、追加批次:PC 收拢动画卡顿修复(d7d3b62c,2026-10-05)
现象与最初假设
诊断:逐帧实测抓到两条根因
改动明细(2 文件 +29/−20)
修复后曲线(同探针复测)
验证(2026-10-05,Playwright MCP 实测)
结语与未尽事项
文章目录
1
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
〇、概览:最终形态与提交清单
一、环境与验证基线
二、批次一:卡片边框体系(74680341)
目标
改动明细
复现要点
三、批次二:悬浮胶囊化(2a00a602)
目标
改动明细
为什么间距不能用 margin
四、批次三:亮黑暗白边框(f6b3d060)
目标
改动明细
本批次踩坑:Vite dev 编译缓存丢更新
五、批次四:三段拆分与下滑收拢动画(b7cce682 上半)
5.1 结构:三段独立胶囊
5.2 动画:下滑收拢为正圆
5.3 本批次的行为语义(验收用)
六、批次五:移动端菜单样式可配置(b7cce682 一部分)
目标
改动明细
切换方法
七、批次六:PC 收拢态快捷面板与「原位弹卡」修复(b7cce682 下半)
7.1 需求
7.2 快捷面板本体
7.3 第一版的 bug 与两处根因
7.4 收拢态子卡可见性::has 守卫
7.5 搜索子卡在桌面端的输入问题
7.6 video 行的误关与忽略名单
7.7 配套 i18n
7.8 本批次最终行为矩阵(自动化实测通过)
八、跨批次踩坑合集
九、如何复现
路径 A:git 一次重放(推荐)
路径 B:按批次手工重做
复现后验收清单
十、最终验证记录(2026-10-05)
十一、提交记录
十二、追加批次:PC 搜索收成图标按钮(04197b82,2026-10-05)
目标
改动明细(4 文件 +37/−72)
为什么面板落位不用改
验证(2026-10-05,Playwright MCP 实测)
十三、追加批次:PC 收拢动画卡顿修复(d7d3b62c,2026-10-05)
现象与最初假设
诊断:逐帧实测抓到两条根因
改动明细(2 文件 +29/−20)
修复后曲线(同探针复测)
验证(2026-10-05,Playwright MCP 实测)
结语与未尽事项