导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
- 1导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)本文
- 2博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
- 3Firefly 博客实战:从零搭建任意设备可用的发布台(Vercel + GitHub API)
- 4评论区回复时表情面板被截断:一次 overflow 裁剪排查实录
- 5Firefly 博客实战:给文章页加阅读进度条与圆环百分比
- 6Firefly 博客接入友链朋友圈:从零开始的完整部署教程
- 7从零开始搭建个人图床
- 8文章中添加音乐播放器
- 9基于Firefly主题的列表封面位置改造
- 10基于rehype的博客PDF嵌入组件实现
- 11Twikoo评论系统部署踩坑记录:Vercel域名无法访问的解决之路
- 12这是我的第一篇BLOG

导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
本文记录 Firefly 博客一次连续的导航栏/卡片 UI 改造:六个已合并提交 + 一条被回滚的实验分支,时间跨度 2026-10-04 至 2026-10-05。写给两类读者:
- 未来的维护者(包括新会话的 AI 助手):读完应知道每一批改了什么、为什么、当时的验证结论与未尽事项;
- 希望复现的访客:文末给出两条复现路径——git 一次重放,或按批次手工重做,全部关键代码在正文中可照抄。
〇、概览:最终形态与提交清单
改造完成后的导航栏最终形态:
- 卡片边框体系:开启
enable-card-border后,全站卡片与导航栏是 2px 纯黑(暗色自动切白)边框,粗细可在显示设置面板用 0–5px 滑块实时调节,持久化到localStorage。 - 悬浮胶囊导航栏:高度从 4.5rem 压扁到 4rem,圆角 9999px,整条导航栏通过 wrapper 的
padding-top: 0.75rem悬浮于顶部之下,间隙条点击穿透。 - 三段独立胶囊 + 下滑收拢动画:导航栏拆为左(logo/站名)、中(菜单)、右(控件)三个独立胶囊;向下滚动超过 80px 时左右两段帘式收起为正圆,中段菜单常显不收;上滑或回顶恢复。
- PC 收拢态快捷面板:收拢后右段只剩一个汉堡按钮(PC 端此时才显示),点击弹出「控件快捷面板」——搜索/音乐/播放/显示设置/亮暗五行;点任意行导航栏保持收拢,原控件的子卡片在原位弹出。移动端汉堡仍开导航链接菜单,两菜单互斥。
- 移动端菜单样式可配置:
siteConfig.nav.mobileMenuStyle支持"card"(旧版浮层卡片,默认)与"drawer"(全屏抽屉)。 - PC 搜索图标化:PC 端导航栏不再内嵌搜索输入框,与移动端统一为一个搜索图标;点击弹出搜索卡片,打开后光标自动落入卡内输入框。
- 收拢动画卡顿修复:PC 右段收拢成圆不再「弹一下→冻结→猛缩」——汉堡由
display切换改为 width 连续过渡,控件组max-width去掉 30rem 死区;亮暗图标切主题时交叉淡化(第十三节)。
| 提交 | 日期 | 内容 | 规模 |
|---|---|---|---|
74680341 | 10-04 | 卡片边框默认 2px 纯黑,显示设置新增边框粗细滑块 | 11 文件 +107/−4 |
2a00a602 | 10-04 | 导航栏改为悬浮胶囊样式并压扁高度 | 4 文件 +33/−25 |
f6b3d060 | 10-04 | 导航栏接入卡片边框,边框色亮黑暗白跟随主题 | 2 文件 +16/−12 |
b7cce682 | 10-05 | 三段拆分、收拢动画、移动菜单配置、PC 快捷面板(含”原位弹卡”修复) | 17 文件 +500/−157 |
04197b82 | 10-05 | PC 搜索收成图标按钮,输入统一走搜索面板(第十二节) | 4 文件 +37/−72 |
d7d3b62c | 10-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)。 - 检查三件套(每批改完必跑):
npx biome check <改动文件> # 格式/静态检查(只读,不用 pnpm lint——会波及无关文件)pnpm type-check # tsc --noEmit --isolatedDeclarationspnpm 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: white2. 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 端右段只剩汉堡(中段菜单已常显导航链接,所以汉堡不该再开链接菜单),期望:
- 汉堡点击打开**「右段被隐藏控件」的快捷面板**——搜索/音乐/播放/显示设置/亮暗五行;
- 点击任意行,导航栏保持收拢,弹出「原来点击控件时会弹出的那张子卡片」(搜索面板、音乐面板、显示设置、亮暗菜单);
- 移动端行为不变:汉堡开导航链接菜单;两菜单互斥。
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 一次重放(推荐)
git clone https://github.com/jmqsOOOtatoba/FireflyPriWeb.gitcd FireflyPriWebgit checkout 020533ee # 系列起点(最后一批之前的基线)git cherry-pick 74680341 2a00a602 f6b3d060 b7cce682 04197b82 d7d3b62c# 若目标仓库没有这几个对象,先从原仓库 fetch,或直接:# git diff 020533ee..d7d3b62c | git apply
pnpm installpnpm dev # http://localhost:4321注:
b7cce682依赖74680341引入的--card-border-width与f6b3d060的--card-border-color,顺序不能乱;04197b82(搜索图标化)依赖b7cce682的三段结构;d7d3b62c(卡顿修复)建立在b7cce682的收拢动画之上,放最后。9e8a7908(18 文章补 author 字段)与本系列无关,可跳过。
路径 B:按批次手工重做
按本文第二、三、四、五、六、七节的「改动明细」顺序执行,每节末尾的复现要点即 checklist:
- 边框体系(第二节):
main.css三规则 +setting-utils.ts四函数 +Layout.astro内联应用 + 显示设置滑块 + i18n; - 悬浮胶囊(第三节):高度 4rem 三处联动 + 9999px 圆角 + wrapper padding/pointer-events 对 + 移动端留边;
- 亮黑暗白(第四节):
--card-border-color变量 + 导航栏!important接管 + 删两组防闪现规则; - 三段与收拢(第五节):Navbar 结构拆分 + navbar.css/main.css retarget + scroll-utils delta 上提 + 正圆公式 + 汉堡显隐普通规则;
- 移动菜单配置(第六节):
mobileMenuStyle配置 + NavMenuPanel 双分支 + click-outside 双注册; - 快捷面板(第七节):quick panel markup + 分流 + 行点击处理器(含
stopImmediatePropagation)+:has守卫拆规则 + 搜索输入框放开(该补丁后被第十二节取代)+ 忽略名单 + i18n 两键; - 搜索图标化(第十二节):删 PC 内嵌
#search-bar+ 图标按钮去lg:hidden!+ 面板输入框去lg:hidden+ 双关键词合并 +togglePanelPC 自动聚焦 + 删收拢态补丁与死忽略项; - 收拢卡顿修复(第十三节):汉堡
display切换改 width/visibility 连续过渡 + 控件组max-width30rem→14rem 去死区 + 删淡入 keyframes + 亮暗图标交叉淡化。
复现后验收清单
npx biome check src/ # 0 错pnpm type-check # 0 错pnpm check # 12 个存量错误(anime.astro 11 + vndb.astro 1),无新增浏览器人工验收(顺序即第七节 7.8 矩阵):
- PC 下滚 >80px → 左右段收成正圆、中段不动、汉堡出现;
- 点汉堡 → 快捷卡弹开、链接菜单不开;
- 逐行点搜索/音乐/显示/亮暗 → 导航栏不展开、对应子卡原位弹出、搜索卡光标已就位;
- 点外部/Esc → 子卡关、仍收拢;再点外部 → 快捷卡关;
- video 行 → 快捷卡不关、背景播放切换;
- 回顶 → 全部恢复、汉堡消失(含第十二节时:面板内输入框全端常驻,不再有
lg:hidden切换); - 移动端(<1024px)→ 汉堡开链接菜单、与快捷卡互斥;
- 显示设置调边框粗细滑块 → 卡片与导航栏边框实时变粗细,刷新后保持;切暗色 → 边框变白;
- 含第十二节时:PC 正常态右段为纯图标排布,点搜索图标 → 卡片弹出、光标自动就位。
十、最终验证记录(2026-10-05)
| 检查项 | 结果 |
|---|---|
npx biome check(改动文件) | 通过,无 fix |
pnpm type-check | 通过 |
pnpm check | 12 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 猛缩 ←—— 卡顿感主体- 汉堡
display单帧切换:非收拢态 PC 汉堡display: none不占位,收拢 class 一挂立刻 +2.75rem,右胶囊首帧向外弹。移动端汉堡常显、宽度不变——与「移动端没问题」的观察完全吻合。 .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 缓存——先怀疑测量工具,再怀疑自己的代码。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




