{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "程序员奇趣博客",
  "home_page_url": "https://kairos.cn.mt",
  "feed_url": "https://kairos.cn.mt/feed.json",
  "description": "基于 Astro 的程序员个人博客与知识工作台",
  "language": "zh-CN",
  "icon": "https://kairos.cn.mt/favicon.svg",
  "favicon": "https://kairos.cn.mt/favicon.svg",
  "authors": [
    {
      "name": "程序员奇趣博客",
      "url": "https://kairos.cn.mt/about"
    }
  ],
  "items": [
    {
      "id": "https://kairos.cn.mt/pitfalls/astro-prerender-content-warning/",
      "url": "https://kairos.cn.mt/pitfalls/astro-prerender-content-warning/",
      "title": "Astro 预渲染时内容集合为空提示",
      "summary": "在 SSR 项目中预渲染内容详情页时集合提示为空的排查——loader 显式配置、schema 校验与构建期防护的完整记录。",
      "content_text": "现象 构建可以完成，但预渲染阶段提示内容集合不存在或为空，文章详情页的 getStaticPaths 拿到空数组，产出 0 个页面——构建是\"绿\"的，内容却静默消失。 根因 Astro 7 的 Content Collections 不再自动推断目录结构，每个集合必须 显式声明 loader 。两个常见的静默失败点： 1. glob 的 base 路径与实际内容目录不一致（比如内容在 src/content/posts/ 而 base 指向了旧的 src/content/blog/ ），loader 不会报错，只会给出空集合； 2. 文件的 frontmatter 不符合该集合的 schema（如日期字段类型不对、必填缺失），条目被整体丢弃。 修复：显式 loader 对齐真实目录 本项目 src/content.config.ts 中每个集合都显式声明 glob loader，且 base 与内容目录一一对应： const posts = defineCollection({ loader: glob({ pattern: ' / .{md,mdx}', base: './src/content/posts' }), schema: postSchema, }); const pitfalls = defineCollection({ loader: glob({ pattern: ' / .{md,mdx}', base: './src/content/pitfalls' }), schema: postSchema.extend({ severity: z.enum(['low', 'medium', 'high']).default('medium'), }), }); 防复发：构建期三道闸 空集合最怕\"静默\"，所以把校验前移： schema 层 ：日期用 z.coerce.date() 、必填 title/description/publishedAt/tags/category ，写错字段类型在 dev 就会看到 400 行错误而不是空结果； 内容检查脚本 （ scripts/check-content.mjs ，挂进 prebuild ）：统计各集合条目数、slug 冲突、 relatedPosts 引用完整性，坏数据直接让构建失败； 详情渲染批测 ：对 sitemap 中全部文章/踩坑/项目/日志详情页断言标题与正文长度——集合又空了，批测立刻可见地失败。 验证方式 最快的自查两连： 1. 集合是否真的读到了条目 npx astro build 2>&1 | grep -oE \"prerendered [0-9]+ pages\" 2. 单个集合计数与内容文件数是否一致 ls src/content/posts/ .mdx | wc -l 两个数字对不上（或页面数骤降为文章数+0），优先查 base 路径与某个文件的 frontmatter 是否把整个集合带崩。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-09-03T00:00:00.000Z",
      "tags": [
        "Astro",
        "预渲染",
        "Content Collections"
      ]
    },
    {
      "id": "https://kairos.cn.mt/projects/content-governance-gates/",
      "url": "https://kairos.cn.mt/projects/content-governance-gates/",
      "title": "内容治理与发布门禁体系",
      "summary": "用 schema 校验、构建期生成、草稿隔离断言与运行时检查组成的四层门禁，保证内容站在\"随便写\"和\"放心发\"之间两全。",
      "content_text": "它解决什么问题 内容站的腐化通常不来自代码，而来自数据：缺字段的 frontmatter、改 slug 后留下的死引用、草稿混进公开索引、发布后页面出现内部术语。这套体系把这些问题挡在\"写作的下一分钟\"，而不是\"读者的下一次点击\"。 四层结构 第 1 层 结构校验 zod schema（内容集合定义）：类型、枚举、默认值、日期 第 2 层 语义校验 scripts/check-content.mjs：引用完整性、slug 唯一、 日期逻辑、字段覆盖率、各集合统计 → content-health.json 第 3 层 生成隔离 prebuild：search:index / graph:data 只读非草稿； 公开产物（索引/图谱/RSS/sitemap/dist）逐一断言无草稿泄漏 第 4 层 运行时门禁 release-gate 脚本对真实响应检查： 全路由 200、站内死链、公开文案禁用词、状态页语义正确 关键决策： 校验先于生成 ：prebuild 顺序固定 content:check → search:index → graph:data → build ，坏数据在最上游失败，下游永远消费干净输入。 草稿隔离用\"产物断言\"而不是\"信任生成器\" ：生成器已过滤草稿，但门禁仍独立扫描每个公开产物是否含任一草稿 ID——双保险，因为这里的失误不可逆（搜索引擎缓存）。 内容健康可视化 ：校验结果落 JSON，后台渲染成清单（缺什么字段、差多少内容达标），作者看到的是可执行条目而非日志墙。 人工判断保留给人 ：门禁只查可机械验证的规则（存在性、格式、泄漏、死链）；文案质量、真实性审核是独立人工环节，草稿必须经它才能转正。 运行方式 CI 与本地同一入口 npm ci && npm run check && npm run lint && npm run test \\ && npm run content:check && npm run build 发布前对运行中的站点： npm run test:release-gate:runtime 历史上这套门禁真实拦下过：新文章标题命中公开页禁用词（改文章，不降规则）、生成索引残留已删路由、 define:vars 脚本在客户端路由下崩溃（由此新增全站交互回归）。 当前状态 四层全部在跑：CI 工作流含全部质量命令；运行时检查接入发布流程；内容后台展示健康度与草稿审核清单。已知边界：禁用词扫描跳过代码块（正文内技术词合法），像素级视觉验收仍需人工（见路线图遗留记录）。 下一步 把\"订阅频道承诺 ⊆ Feed 覆盖\"这类跨产物一致性规则补进第 3 层；为运行时检查增加性能断言（复用 LHCI 产物而非另起浏览器会话）。",
      "date_published": "2026-09-03T00:00:00.000Z",
      "date_modified": "2026-09-03T00:00:00.000Z",
      "tags": [
        "Astro",
        "CI",
        "内容系统",
        "质量保障"
      ]
    },
    {
      "id": "https://kairos.cn.mt/projects/knowledge-constellation-explorer/",
      "url": "https://kairos.cn.mt/projects/knowledge-constellation-explorer/",
      "title": "知识星图浏览器",
      "summary": "把文章内容编译成关系图并在 Canvas 上可交互探索：构建期生成、力导向渲染、路径查询与无鼠标等价操作的系统说明。",
      "content_text": "它解决什么问题 \"这个站写过哪些东西、彼此怎么关联\"是传统博客回答不了的问题。知识星图把文章、项目、踩坑、笔记、标签、系列编译成一张可探索的关系网——读者可以从任何一颗星出发，顺着引力找到相关的一切。 数据链路 内容集合（frontmatter 关系字段） → scripts/generate-graph-data.mjs（构建期，仅非草稿） → public/graph-data.json（当前构建：172 节点 / 253 边） → /graph SSR 页面读取（磁盘直读，见文章《SSR 页面为什么不该向自己发 HTTP 请求》） 边类型：文章↔标签、文章↔系列、项目↔相关文章、踩坑↔标签——全部来自已校验的 frontmatter 引用（引用完整性由内容门禁保证，图里不会出现悬空边）。 渲染层的四个工程决策 1. 力导向自研而非引库 ：确定性初始布局（同一输入同一画面，可回归）、O(n²) 斥力 + 弹簧 + 向心力、alpha 衰减自动停帧——当前规模几百毫秒收敛，浏览器里算得起，无需 Web Worker。 2. 性能自律 ： requestAnimationFrame 只在有能量时运行； IntersectionObserver 离开视口即停帧，后台标签页暂停；拖拽/缩放/平移/双击复位全在 Canvas 坐标系内完成，不触发布局。 3. 降级是设计的一部分 ： prefers-reduced-motion 下同步结算 240 次迭代输出静态星图——信息量不变，只是不\"飘\"；移动端粒子/光效降级的同一原则在这里复用。 4. 无鼠标等价操作 ：星图是可发现性增强，不是唯一入口——节点列表（可过滤、键盘可达、Enter 聚焦星图对应节点）、类型筛选、路径查询（起点/终点下拉 + 本地 BFS 最短路径，含\"无可达路径\"空态）全部可纯键盘完成。 交互模型 点击节点 → 邻域高亮（相连边标记 source/target、非相关弱化）+ 邻接面板列出关系；类型筛选与关键词过滤联动重排；BFS 路径提示展示\"节点 A → B → C\"与跳数。数据在页面内联一份公开 payload（构建期直读生成物），过滤/路径全部内存态——不持久化、不上传任何输入。 当前状态与边界 星图模式已成为探索地图默认视图（节点阈值达标后从二级入口升级）。边界：图规模到千级节点时 O(n²) 斥力需要换成 Barnes-Hut 或分块布局，页面结构已为此预留（渲染器与数据层解耦，输入只是那份 JSON）。 下一步 方向筛选（只看\"文章→标签\"类边）、跨类型路径高亮动画（尊重 reduced-motion）、以及基于入度的\"枢纽文章\"榜单接入首页。",
      "date_published": "2026-09-03T00:00:00.000Z",
      "date_modified": "2026-09-03T00:00:00.000Z",
      "tags": [
        "Canvas",
        "数据可视化",
        "图算法",
        "Astro"
      ]
    },
    {
      "id": "https://kairos.cn.mt/projects/weekly-digest-pipeline/",
      "url": "https://kairos.cn.mt/projects/weekly-digest-pipeline/",
      "title": "周报订阅与投递流水线",
      "summary": "从邮箱确认、MySQL 持久化到可插拔邮件 provider 的订阅系统：状态机、幂等、重试和投递回执的完整链路说明。",
      "content_text": "它解决什么问题 内容站希望读者\"按主题订阅更新\"，但订阅是一个容易做错的功能：要防误填他人邮箱、要防刷、要在数据库故障时不丢状态、要保证确认和退订链接重复点击不出错。这套流水线覆盖从表单到回执的完整链路。 架构与关键决策 表单 → POST /api/subscribe（校验+限流） → subscription-store（pending 记录，MySQL 权威存储） → 确认邮件（token 链接）→ /api/subscribe/confirm → active / 退订 → unsubscribed 后台：周报预览（/admin/weekly-digest）→ POST /api/admin/weekly-digest 投递 → email provider adapter（console | http-api）→ 投递记录 + 回执 双阶段确认（double opt-in） ：提交只产生 pending ，邮箱点击确认后才 active ——防止把别人邮箱填进列表。 状态机九态 ： pending / confirmed / already-active / unsubscribed / already-unsubscribed / expired / invalid / conflict / rate-limited ，公开响应不返回 token 本体，确认与退订幂等（重复点击返回同型结果，不重复变更）。 fail-closed 存储保护 ：生产缺少 DATABASE URL 时禁止订阅写入而非静默落内存；开发态内存存储与 MySQL 存储共用同一接口层（ subscription-store ）。 可插拔邮件 adapter ： console （沙箱打印）与 http-api （配置化外呼）两种 provider；发送开关默认关闭，投递记录表保存每封的状态、失败原因与最多 3 次的有限重试，周期幂等防同一周重复群发。 限流按 IP+账号维度 ，10 分钟窗口 5 次，429 响应携带可渲染文案。 难点与处理 1. 错误语义透传 ：服务故障（503）与输入错误（400）不能共用一套前端文案——接口为每个失败分支返回独立 state + message ，前端优先渲染服务端 message（详见踩坑《订阅接口 503 被前端翻译成了\"邮箱格式不对\"》）。 2. 无 JS 路径可用 ：表单原生 POST 提交到同一端点，服务端 303 重定向到带状态参数的结果页，JS 版只是把反馈前置为行内提示（详见文章《渐进增强不是口号》）。 3. 数据迁移安全 ：订阅表走版本化 migration（ schema migrations + advisory lock + checksum），已在 staging 完成备份/隔离库恢复演练。 当前状态与边界 已完成：全链路沙箱闭环、migration 与恢复演练、provider adapter 与投递记录。 待完成：staging 真实发送回执验证与生产部署验收（受外部授权阻塞）。系统在所有未验证环节均选择显式失败而不是模拟成功。 下一步 真实 SMTP 验证通过后接入 bounce 处理回调；订阅统计看板增加按主题的确认率视图。",
      "date_published": "2026-09-03T00:00:00.000Z",
      "date_modified": "2026-09-03T00:00:00.000Z",
      "tags": [
        "Node.js",
        "MySQL",
        "订阅",
        "邮件投递"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/accessibility-automated-assertions/",
      "url": "https://kairos.cn.mt/posts/accessibility-automated-assertions/",
      "title": "无障碍不是一次审计，是一串可自动化的断言",
      "summary": "把键盘可达、焦点可见、对比度、live region、reduced-motion 从\"上线前人工点一遍\"变成可回归的自动化检查清单。",
      "content_text": "无障碍审计最大的敌人不是\"不知道标准\"，而是 一次性 ：上线前请人点一遍、修一轮、三个月后新功能把老问题全带回来。解药和所有工程问题一样——把要求翻译成能进 CI 的断言。 分层：哪些能自动查，哪些必须人看 | 层 | 工具 | 能覆盖 | 不能覆盖 | | -------- | --------------------------------------------------- | ------------------------------------- | -------------- | | 静态规则 | axe-core / eslint-plugin-jsx-a118 / Lighthouse a11y | 缺 label、role 误用、对比度、landmark | 交互流是否合理 | | 键盘行为 | Playwright 驱动 | Tab 顺序、焦点陷阱、Esc、方向键 | \"好不好用\" | | 视觉偏好 | 媒体特征模拟 | reduced-motion 全静态、强制色彩 | 主观可读性 | | 读屏实测 | 人工（NVDA/VoiceOver） | 语义是否\"听起来对\" | 无法自动化 | 自动化吃下前三层，人只负责第四层——这是个人站点也养得起的分工。 五条最值得固化的断言 1. 键盘可达核心流 （每页模板化）： await page.keyboard.press('Tab'); // skip-link 第一站 // 断言：纯键盘完成 搜索→选结果→进入详情 const focusables = await page.evaluate( () => [ ...document.querySelectorAll( 'a,button,input,select,textarea,[tabindex=\"0\"]', ), ].filter((el) => el.offsetParent !== null).length, ); 2. 焦点永远可见 ：全站禁止\"只有颜色变化\"的 focus 态——断言自定义控件存在 :focus-visible 规则且伴随非颜色信号（outline/border/背景差）： const outline = await page.evaluate(() => { const el = document.activeElement; return getComputedStyle(el).outlineStyle; // 期望非 'none'，或 box-shadow/背景有可辨变化 }); 3. 模态必带焦点管理 ：每个弹层三条断言——打开后焦点在弹层内、Tab×N 不逃逸、关闭后回到触发元素。这三行是回归事故（焦点丢失到 body、Tab 掉进已卸载按钮）的捕获器。 4. 状态播报 ：一切\"结果会异步变化\"的区域必须有 live region： const live = await page.evaluate(() => [...document.querySelectorAll('[aria-live]')].map((n) => n.id), ); // 搜索计数、提交结果、成就解锁 → 断言注册在列 5. reduced-motion 全静态且信息不减 ：启动 reducedMotion: 'reduce' 的 context，逐页断言：无 running 动画、所有 [data-scroll-reveal] 内容可见、动画承载的状态仍有文本/颜色替身。本项目用 13 项检查把六页跑成了一条命令。 把颜色对比度变成数值而不是感觉 深色主题的对比度问题肉眼不可靠（显示器亮度、环境光都在骗人），交给计算——Lighthouse a11y 满分时它内置的 color-contrast 规则已经逐元素核过一遍。把 --lhci 纳入发布流程，等于免费雇了一个对比度审计员；一次真实修复（全局 a{color:inherit} 压过按钮文字色导致 1.73:1）就是它抓出来的。 反模式清单（规则能查的） ：没有键盘语义——要求交互节点是 button/a 或显式补 role + tabIndex + keydown ； 图标按钮无文本：必须有 aria-label 或视觉隐藏的文本子节点； 只用颜色传达状态：错误=红、成功=绿之外必须有图标/文本； aria-hidden 的容器里残留可聚焦元素：等于给键盘用户造黑洞——隐藏需连 inert/tabindex=-1 一起。 流程建议 1. 每个新交互组件的 PR 附\"上表五条断言\"中适用的行（写进组件模板，让复制测试比跳过测试容易）； 2. CI 跑静态规则 + 关键页 reduced-motion； 3. 发布前全量跑一遍带 Lighthouse 的门禁； 4. 每季度人工读屏一次核心流——这一项接受无法自动化的事实。 小结 无障碍的质量不取决于你懂多少 ARIA 规范，而取决于 三个月后新功能进来时，老规矩还在不在 。把标准翻译成断言，是把个人意志变成系统能力——键盘用户、读屏用户、对动效敏感的用户，从此不再依赖任何人\"记得\"测试。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "可访问性",
        "测试",
        "键盘导航",
        "ARIA"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/animation-compositor-performance/",
      "url": "https://kairos.cn.mt/posts/animation-compositor-performance/",
      "title": "让动画跑在合成层：transform/opacity 与 layout thrash 的边界",
      "summary": "前端动效性能的核心是只动 GPU 能独立处理的属性。讲清 transform/opacity 为何安全、哪些属性会触发布局重排、以及读写分离避免强制同步布局。",
      "content_text": "流畅动画的差别不在用了多少特效，而在每一帧浏览器被迫做多少工作。理解渲染管线，就能预判一个动画是 60fps 还是掉帧。 浏览器渲染四步 JS → Style（计算样式）→ Layout（几何位置）→ Paint（填色）→ Composite（合成上屏） 每帧重跑代价递减：Layout 比 Paint 贵，Paint 比 Composite 贵。理想动画只走最后一步—— Composite ，因为合成由 GPU 处理，主线程几乎不参与。 只有 transform 和 opacity 能\"只合成\" 改动 transform （translate/scale/rotate）和 opacity 时，元素已被提升为独立合成层，浏览器只需重新合成， 跳过 Layout 与 Paint ： .card-enter { transform: translateY(0.75rem); opacity: 0; } .card-enter-active { transform: translateY(0); opacity: 1; transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms; } 而下面这些属性每变一次都会触发昂贵的重排或重绘： | 动画属性 | 触发的最贵阶段 | | --------------------------------------------------------- | -------------- | | top/left/right/bottom 、 width/height 、 margin/padding | Layout | | box-shadow 、 filter: blur() 、 background | Paint | | font-size 、 line-height | Layout | 同样\"上浮淡入\"，用 top 位移是每帧 Layout + 父级重排，用 transform 是纯合成——这就是为什么动效规范里写\"位移一律 translate，不用 top\"。 会触发 Layout 的\"隐形雷区\"：JS 读几何 即使动画只用 transform，如果在 scroll / resize 回调里 同步读取几何属性再立刻写入 ，会触发\"强制同步布局\"（layout thrash）： // 反面：每个元素读一次 offsetTop 写一次 style，读被写强制刷新布局 → N 次重排 items.forEach((el) => { el.style.transform = translateY(${el.offsetTop}px) ; }); 读写分离 ：先批量读，再批量写： const tops = items.map((el) => el.offsetTop); // 只读一次布局 requestAnimationFrame(() => { items.forEach((el, i) => { el.style.transform = translateY(${tops[i]}px) ; }); // 集中写 }); 高频事件（scroll）里还应加节流——用 requestAnimationFrame 把多次事件合并到一帧更新一次，而不是每个事件都读写。 will-change：用对是加速，用满是负债 will-change: transform 提前把元素提成合成层，动画开始前给一帧提示有益。但它有内存代价（每层独立纹理），滥用会让 GPU 内存吃紧、移动端反而更卡。原则： 只在 即将动画 的元素上加，动画结束后移除； 不要给大量列表项同时常驻 will-change ； 揭示完成后释放： el.dataset.revealed && el.style.willChange = 'auto' 。 box-shadow / filter 的光晕怎么办 光晕动效常需 box-shadow 过渡，而它是 Paint 级。折中： 静态光晕用 box-shadow （不变就不重绘）； 需要\"发光呼吸\"时用叠加一个 opacity 动画的伪元素层，把 Paint 转成合成—— ::after 画好阴影，只动它的 opacity 。 用 reduced-motion 兜底性能与体验 无论动画多便宜，都要尊重 prefers-reduced-motion ： @media (prefers-reduced-motion: reduce) { { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; } } 这既是无障碍要求（前庭功能敏感用户），也是低端设备的事实降载。 小结 动效性能的功课就三条：只动 transform/opacity 、读写分离不制造强制同步布局、 will-change 点到为止。把这三条内化成肌肉记忆，比事后拿性能面板逐帧救火省力得多。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "性能",
        "动效",
        "渲染",
        "CSS"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/article-image-lazy-load/",
      "url": "https://kairos.cn.mt/posts/article-image-lazy-load/",
      "title": "文章页图片的懒加载与淡入：不制造布局偏移的做法",
      "summary": "正文图片用 loading=lazy + decode=async 延迟解码，配合占位尺寸与淡入，兼顾性能与体验，并处理失败与 reduced-motion。",
      "content_text": "文章里一张大图，处理不好就是 LCP 杀手 + CLS 元凶。好消息是：绝大多数收益靠几行\"该加的加上、该设的设对\"就能拿到。 基线三件套 <img src=\"/img/diagram.png\" alt=\"架构示意图\" width=\"1200\" height=\"800\" loading=\"lazy\" decoding=\"async\" /> width/height （或 CSS aspect-ratio ）是防 CLS 的第一功臣 ——浏览器据比例预留占位空间，图片加载完成不撑动版面。缺尺寸的图是内容站最常见的布局偏移来源。 loading=\"lazy\" ：离视口远的图不请求。 首屏 LCP 图千万别加 ——懒加载会延迟它自己的加载，把最大内容元素推后。判断标准：位于首屏折叠线以上的第一张图，用 loading=\"eager\" 甚至 fetchpriority=\"high\" 。 decoding=\"async\" ：解码移出主线程，避免大图解码卡顿交互。 CSS 侧兜住尺寸 属性宽高会被样式覆盖，用 CSS 强制保持比例、限制不溢出容器： .article-content img { max-width: 100%; height: auto; aspect-ratio: attr(width) / attr(height); / 现代浏览器据属性自动推比例，这行作显式保险 / } 实际现代浏览器已由 width/height 属性自动推 Aspect Ratio，CSS 这行更多是\"确保不被某处 height: auto 之外的规则破坏\"。 淡入：锦上添花但要处理边界 想给图片加\"加载完成淡入\"，务必处理三种\"到不了 loaded\"的情况，否则图会永久隐身： function enhance(img) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; // 直接可见 if (img.complete && img.naturalWidth) return; // 已缓存：不淡入，直接显示 img.dataset.reveal = 'pending'; const reveal = () => { img.dataset.reveal = 'done'; cleanup(); }; const fail = () => { img.dataset.reveal = 'done'; cleanup(); }; // 加载失败也要可见（别留隐藏态） const cleanup = () => { img.removeEventListener('load', reveal); img.removeEventListener('error', fail); }; img.addEventListener('load', reveal); img.addEventListener('error', fail); } [data-reveal='pending'] { opacity: 0; } [data-reveal='done'] { opacity: 1; transition: opacity 400ms ease; } 三个边界对应三条教训： 1. reduced-motion 用户跳过淡入，直接可见； 2. 命中缓存的图 complete=true ， load 事件不会再触发——不判断就会卡在 pending 永久透明； 3. 加载 失败 也必须解除隐藏——否则一张 404 图变成一块永远空白的隐形坑（比显示破图标更糟）。 客户端路由下还要在换页时移除这些 load/error 监听，避免泄漏。 响应式与格式 需要真响应式尺寸时用 srcset/sizes ，让浏览器按 DPR 与视口挑选； 截图/图表类优先 WebP/AVIF（带 type 的 或 srcset 回退）； 装饰性图 alt=\"\" （不是省略 alt），信息性图 alt 描述内容而非文件名。 小结 图片优化的收益顺序很明确： 设尺寸防 CLS > 别懒加载 LCP 图 > 异步解码 > 现代格式 > 淡入动效 。前四条是纯工程正确，最后一条是体验糖——而糖必须把\"缓存/失败/减弱动效\"三个苦口处理掉，才不会变成新 bug。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "图片优化",
        "性能",
        "CLS",
        "动效"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/clipboard-api-failure-feedback/",
      "url": "https://kairos.cn.mt/posts/clipboard-api-failure-feedback/",
      "title": "剪贴板 API 的现实：安全上下文、权限与失败反馈",
      "summary": "navigator.clipboard 在非 HTTPS、无焦点、权限拒绝下的行为差异，以及为复制按钮补齐成功与失败双反馈的实践模式。",
      "content_text": "\"点击复制\"是工具页和代码块上最不起眼、也最容易做错的功能。做错的常见姿势是：只处理了成功。 三条铁律 navigator.clipboard.writeText() 只在以下前提成立时存在且可用： 1. 安全上下文 ：HTTPS 或 localhost 。 http:// 局域网 IP 访问时 navigator.clipboard 干脆是 undefined 。 2. 文档获得过用户手势 ：多数浏览器要求调用栈可以追溯到一次真实点击/按键，脚本自动触发会被拒绝。 3. 权限与焦点 ：页面不可见或用户在权限策略中拒绝了剪贴板写入时，Promise 以 NotAllowedError 拒绝。 任何一条不满足， clipboard API 路径就走不通——所以复制功能必须有\"失败也要被说出来\"的设计。 失败反馈是最常被遗漏的分支 一个真实做对的复制按钮应当区分三种结果： const copyText = async (text, button) => { const label = button.querySelector('[data-label]'); const original = label.textContent; try { if (!navigator.clipboard?.writeText) { throw new Error('insecure-context'); } await navigator.clipboard.writeText(text); label.textContent = '已复制'; button.classList.add('is-copied'); } catch { // 关键分支：不假装成功 label.textContent = '复制失败，请手动选择复制'; } finally { window.setTimeout(() => { label.textContent = original; button.classList.remove('is-copied'); }, 1600); } }; 两个细节： is-copied 的视觉反馈只在成功路径加上 ；失败时用文案差异即可，不要让失败看起来像成功。 定时器要记账 。若页面跑在客户端路由下（脚本体会重新执行），保存这些 timeout 并在换页前清除，否则残留回调会写进已经卸载的 DOM——这是动效批次的复盘教训在复制组件上的重演。 旧世界：execCommand 与 textarea 兜底 document.execCommand('copy') 早已废弃，且在现代浏览器中同样受权限约束，只是报错方式更暧昧（返回 false ）。它唯一还值得存在的理由是非安全上下文的兜底： const legacyCopy = (text) => { const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.opacity = '0'; document.body.append(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok; }; 先试 async API、失败再落 legacyCopy 、两条路都不通才提示手动复制——三层漏斗能让绝大多数用户拿到内容，同时保留诚实的兜底话术。 读剪贴板是另一回事 readText() 的权限模型严格得多（通常要求用户显式手势 + 权限弹窗），粘贴类工具（JSON 校验器、diff 对比器）应当提供\"手动粘贴\"输入区作为一等公民，把\"一键读取剪贴板\"当作锦上添花的快捷路径，而不是唯一入口。 反馈动效的克制 复制成功的动画（短脉冲、像素星星之类）适可而止：一次性、≤1 秒、可被 prefers-reduced-motion 完全关闭，关闭后文本反馈\"已复制\"依然在场。动画是强调，不是信息载体本身。 小结 剪贴板功能的质量不在\"能不能复制\"，而在 不能复制的那一刻你说了什么 。给失败一个准确、可操作的出口（选中手动复制的提示），比在成功时多放一颗星星更能建立信任。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "浏览器 API",
        "剪贴板",
        "安全",
        "交互细节"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/command-palette-accessibility-checklist/",
      "url": "https://kairos.cn.mt/posts/command-palette-accessibility-checklist/",
      "title": "一个可访问的命令面板：从快捷键到焦点管理的全清单",
      "summary": "实现 Ctrl/Cmd+K 命令面板时的键盘协议、combobox 语义、焦点陷阱与恢复、移动端适配和加载态处理的完整检查清单。",
      "content_text": "命令面板是\"高级感\"功能里最容易做出形、丢出神的：视觉上一眼像模像样，键盘用户却在三米外迷路。本文把验收一个命令面板需要过的问题列成清单，每一条都对应真实实现过的代码。 1. 唤起与关闭 快捷键： Ctrl/Cmd + K 全局监听；在 input/textarea/[contenteditable] 聚焦时不劫持其它单键快捷键。 关闭途径必须至少三条： Esc 、遮罩点击、显式关闭按钮。面板内的按钮不要用 tabindex=\"-1\" 把自己藏起来。 关闭时清理：移除焦点圈监听、恢复 body 滚动锁、移除临时 toast。 2. 语义：dialog + combobox 两层结构 外层面板是模态： <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"palette-title\" hidden 内层输入框是组合框（combobox），结果列表是 listbox，两者用 aria-controls / aria-activedescendant 关联： <input role=\"combobox\" aria-expanded=\"true\" aria-controls=\"palette-results\" aria-activedescendant=\"option-3\" /> … 关键决策： 选项用 aria-activedescendant 而不是让焦点真的落进列表 。焦点始终留在输入框，上下键只移动\"激活\"状态。这样输入法组合、退格编辑都不会丢，也是 ARIA 作者工具箱里 listbox 模式的正统做法。 3. 焦点：去哪了，怎么回来 打开面板： 1. 记录 document.activeElement 作为恢复锚点； 2. 焦点进入输入框（ focus({ preventScroll: true }) ，避免弹层刚出现就滚动页面）； 3. 焦点陷阱 ：监听 Tab ，到达最后一个可聚焦元素后回卷到第一个（ Shift+Tab 反向）。 aria-modal=\"true\" 只是给读屏器的声明，不能阻止 Tab 走出弹层，陷阱必须自己写。 关闭面板：焦点还给锚点。跳过这一步，键盘用户会被扔回页面顶部。 4. 键盘协议全表 | 键 | 行为 | | ----- | ----------------------------------------------------------- | | ↑ / ↓ | 移动激活项， scrollIntoView({ block: 'nearest' }) 保证可见 | | Enter | 激活当前项（链接跳转或执行动作命令） | | Esc | 关闭并恢复焦点 | | Tab | 在面板内圈闭移动（访问关闭按钮等） | 列表为空时上下键应当无操作而不是报错；只有一个结果时 Enter 直接命中，省一次按键。 5. 加载态与空状态是设计的一部分 索引异步加载时，面板不能装作\"无结果\"。三态文案： 索引未到： 正在加载站内索引… （可先渲染无需索引的导航推荐项，索引到达后增量合并）； 有输入无匹配： 没有找到匹配内容。试试更短的关键词，或浏览推荐入口。 ； 无输入：展示推荐入口 + 最近公开内容。 面板打开的视觉就绪不应等待任何 fetch——先聚焦、先渲染骨架，网络只是后续的数据补全。 6. 跳转不要整页刷新 面板内导航若用 window.location.href = ，会把站点的 View Transitions 客户端路由整个旁路掉：整页重载、过渡动画丢失、SPA 状态清零。同域跳转改为程序化锚点点击，让路由拦截器接管： const link = document.createElement('a'); link.href = target; document.body.append(link); link.click(); link.remove(); 路由不可用时浏览器自动回退为普通导航，零额外成本。 7. 移动端与 reduced-motion 宽度断点：≤640px 时面板改为全宽贴顶，内边距收紧，选项网格从三列降为一列； 触屏用户没有 Esc：遮罩点击与关闭按钮权重上升，关闭区触控高度不低于 44px； prefers-reduced-motion: reduce ：关闭选项 hover 位移与面板缩放入场，保留边框高亮等静态可辨状态。 8. 验收脚本思路 把上表变成可回归的测试：Playwright 下 Control+k → 断言 document.activeElement 为输入框 → 连按 12 次 Tab → 断言焦点仍在 #command-palette 内 → 输入关键词 Enter → 断言 URL 变化且 window 上的标记变量存活（证明 SPA 导航未重载）。比人工点击可靠，也比快照测试更能抓住\"焦点逃逸\"这类逻辑缺陷。 小结 命令面板的\"高级感\"来自毫秒级的响应与丝滑动画，而它的专业底线是那几条看不见的规矩：焦点进得来、出不去、还得回来；空与加载各有各的话；跳转不破坏路由。先把规矩跑通，再谈惊艳。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "可访问性",
        "键盘导航",
        "ARIA",
        "交互设计"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/content-integrity-build-checks/",
      "url": "https://kairos.cn.mt/posts/content-integrity-build-checks/",
      "title": "内容一致性校验：把坏数据挡在构建期而不是发布后",
      "summary": "个人内容站如何在构建阶段用脚本校验 frontmatter、slug 引用、日期逻辑、阅读时长与关联关系，形成可持续的内容卫生机制。",
      "content_text": "内容站的 bug 大多不是代码 bug，而是 数据 bug ：写错的日期、被改名的文章留下的死引用、缺 frontmatter 字段的半成品。这些问题构建不会报错，只会在读者点开的瞬间变成空白页或 404。解法是给内容层装一道\"构建期校验\"。 值得校验的六类问题 1. 必填与格式 ：字段缺失在 schema 层拦（zod/内容集合 schema），格式在脚本层兜： // 阅读时长字段必须符合 \"N 分钟\"，避免混入 \"5 mins\" / \"约五分钟\" const readingTimePattern = /^[1-9]\\d \\s 分钟$/; 2. 引用完整性 ： relatedPosts 、项目关联文章、系列成员这些\"指向别的文档\"的字段，构建期逐个解析目标是否存在： for (const ref of data.relatedPosts ?? []) { if (!postIds.has(ref)) issues.push( ${file}: relatedPost \"${ref}\" 不存在 ); } 这是收益最高的一条——slug 改名后忘记同步引用，前端表现为\"关联文章点开 404\"，而只有这条断言能提前抓到。 3. 日期逻辑 ： updatedAt < publishedAt 必然是笔误（除非你允许未来预约发布，那也要单独校验）；RSS 排序、图谱时间衰减都依赖这个不变量。 4. slug 唯一性 ：不同目录同名 slug、大小写碰撞，在静态路由生成阶段直接互相覆盖——提前在校验里报。 5. 公开产物的草稿泄漏 ： draft: true 的文章绝不能出现在 search-index.json 、 graph-data.json 、RSS、sitemap、 dist/ 里。这条要作为发布门禁（对每个公开产物断言不含任一草稿 ID）： const draftSlugs = collectDraftIds(); // 从内容集合读 for (const artifact of ['search-index', 'graph', 'rss', 'sitemap']) { const leaked = findIdsInArtifact(artifact).filter((id) => draftSlugs.has(id)); if (leaked.length) fail(artifact, leaked); } 6. 覆盖度报表 ：必填字段覆盖率、各集合数量（文章/踩坑/项目），输出成机器可读 JSON 供后台\"内容健康\"页消费——把\"还差多少内容达标\"从感觉变成数字。 分层：schema 管结构，脚本管语义 内容集合 schema（zod）适合声明式约束：类型、枚举、默认值、正则。它管不了跨文档引用和集合级统计。两级配合： zod schema（结构合法性）→ check-content.mjs（语义/引用/统计）→ 生成器（索引/图谱/RSS） 顺序很关键： 校验在生成之前 ，坏数据直接让构建失败，而不是生成出半错的索引再靠下游兜。本项目把它接进 prebuild ，任何 npm run build 自动执行。 严重度与豁免 校验脚本要区分 error （挡构建）与 warning （记录不挡）：缺标题是 error，缺 difficulty 可以只是 warning（有默认值）。全 error 策略的结局一定是有人绕过门禁，分层策略才能长期活着。临时豁免要有过期日： // 允许豁免，但必须写理由和到期日，过期自动升级为 error { file: 'old-post.mdx', rule: 'missing-updatedAt', until: '2026-10-01', reason: '存量迁移中' } 反馈形态决定执行力 校验结果不要只丢在终端日志里。落一份 content-health.json ，后台页渲染成清单——作者改内容时看到的应该是\"3 篇文章缺 description\"这样可执行的条目，而不是一屏 grep 输出。数据、门禁、可视化三件套齐了，内容卫生才是可持续机制而不是一次性大扫除。 小结 代码有类型系统兜底，内容凭什么裸奔？把 frontmatter 当 schema、把跨文档引用当外键、把公开产物当边界测试对象，用一条 prebuild 命令把六类数据 bug 挡在构建期——这是内容站在\"写作自由\"和\"发布可靠\"之间唯一划算的折中。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "内容治理",
        "构建",
        "CI",
        "数据校验"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/css-cascade-layers-in-practice/",
      "url": "https://kairos.cn.mt/posts/css-cascade-layers-in-practice/",
      "title": "CSS 级联层实战：为什么 @layer 能救回你的工具类",
      "summary": "用 @layer 调停基础样式与工具类的优先级之争：未分层规则为何总是赢家，如何用 base 层修正级联，以及哪些规则应该留在层外。",
      "content_text": "本文为通用技术教程，示例均可在现代浏览器中直接复现。 一个诡异的 Bug 假设站点里有这样一条再普通不过的基础样式： a { color: inherit; text-decoration: none; } 然后给某个链接加一个 Tailwind 工具类换色： 订阅 预期是文字变成亮色，实际却纹丝不动——工具类\"失效\"了。检查特异性？类选择器（0,1,0）明明高于元素选择器（0,0,1）。检查源码顺序？把工具类挪到最后也没用。 问题不在特异性，也不在顺序，而在 级联层 。 级联层规则：未分层即最高优先 CSS Cascade Layers 的裁定顺序是：先比较\"有没有层\"，再比较特异性。 所有 @layer 内的样式，彼此之间按层顺序裁定； 未分层的作者样式，无条件赢过任何层里的样式 ——无论特异性多低。 Tailwind v4 把工具类输出在 @layer utilities 中。于是： @layer utilities { .text-brand { color: var(--brand); } / 层内 / } a { color: inherit; } / 层外 / 层外的 a 直接胜出。这解释了开头的 Bug：不是工具类没生成，而是它在层里\"天生低人一等\"。 顺带一提，这个规则还会让 hover: 变体悄悄失灵—— a:hover { color: … } 若写在层外，即使特异性只有 (0,1,1) 也能压过层内的任何工具类。 修复：把元素级默认样式放进 base 层 Tailwind v4 在样式表开头声明了层顺序： @layer theme, base, components, utilities; 只要把自己的元素级默认样式放进同名 base 层， utilities 就会按声明顺序正确地覆盖它： @layer base { html { background: rgb(var(--bg)); } body { color: rgb(var(--text)); font-family: var(--font-sans); } a { color: inherit; text-decoration: none; } :focus-visible { outline: 2px solid rgb(var(--accent)); } } 修复后的裁定顺序变成： 1. 浏览器默认（user agent）； 2. base 层的元素默认； 3. utilities 层的工具类——赢家。 反过来：哪些规则应该留在层外 层外规则是\"终极王牌\"，要克制地使用。适合留在层外的只有两类： 1. Token 定义 ： :root { --accent-primary: … } 和主题覆盖 html[data-accent='…'] 。它们是整套样式的地基，必须稳赢任何组件层的意外干扰。 2. 刻意的兜底/重置 ：确信要压过一切工具类的历史包袱修复，配上注释说明原因。 一个判断标准：这条规则如果被某个工具类覆盖，是\"坏了\"还是\"对了\"？答案是\"对了\"，就该进层。 排查工具 遇到\"类写了却不生效\"，按这个顺序查： 1. DevTools 的 Styles 面板：看规则是否出现。出现了但被划掉，看划掉它的规则来自哪一层（面板会标注 layer ）。 2. 控制台验证匹配： document.querySelector(sel).matches('.text-\\\\[var\\\\(--x\\\\)\\\\]') 。 3. 检查层归属： getComputedStyle 不显示层级，最直观的方式是在 Styles 面板看每条规则旁边的 layer 徽标。 小结 级联层把\"谁赢\"从特异性博弈简化成了声明顺序：theme → base → components → utilities。基础样式进 base 层，工具类在 utilities 层稳赢；token 留在层外当地基。理解这一条，整个设计系统的层叠就再也不靠运气。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CSS",
        "级联层",
        "设计系统"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/css-container-queries-in-practice/",
      "url": "https://kairos.cn.mt/posts/css-container-queries-in-practice/",
      "title": "容器查询落地：让卡片在自己的容器里决定布局",
      "summary": "@container 把\"响应式\"从视口级细化到组件级。以文章卡片在单列/双列/侧栏多场景复用为例，讲查询条件、回退策略与迁移边界。",
      "content_text": "媒体查询回答\"屏幕多宽\"，容器查询回答\"我这块地方多大\"。对组件库来说，后者才是它真正的痛点：一张卡片被放进全宽列表、双列网格、三列网格、窄侧栏时，它的理想内部布局各不相同，但屏幕宽度却可能完全一样。 最小语法 / 声明容器 / .card-slot { container-type: inline-size; container-name: card; } / 子元素按容器宽度切换 / @container card (min-width: 40rem) { .card { display: grid; grid-template-columns: 8rem 1fr; } } container-type: inline-size 让容器按行内尺寸（逻辑上的\"宽度\"）提供查询基准；子树内任何元素都能 @container 查询它，不必是直接子级。 一个真实的复用场景 文章推荐卡需要出现在三种环境：全宽（单列+横排大缩略区）、双列网格（竖排）、侧栏（竖排紧凑）。纯媒体查询方案要为每种页面断点写一遍覆盖，而容器查询只需卡片自己声明： .slot { container-type: inline-size; } @container (min-width: 30rem) { .post-card { grid-template-columns: 1fr; } / 够宽：标题描述并排余裕 / .post-card excerpt { -webkit-line-clamp: 3; } / 多展示一行 / } @container (max-width: 20rem) { .post-card meta { flex-direction: column; } / 很窄：meta 换行堆叠 / } 卡片被放进哪个容器，就按那个容器说话。新增页面布局时零成本——这正是组件级响应式的复利。 边界与坑 1. container-type 会让容器成为包含块 ，内部 position: absolute 的定位基准随之改变；含浮出元素（tooltip、下拉）的容器要先验证。 2. 只能按行内尺寸和样式查询 （ inline-size / size / style() ），容器查询不能基于内容高度触发——那仍是 JS/ IntersectionObserver 的领域（ container-type: size 会要求块方向尺寸也固定，实践中很少用）。 3. 回退 ：不支持的浏览器直接忽略 @container 块，样式安全退化到基线布局。给基线一套\"哪个宽度都不算错\"的默认样式即可，无需 JS 检测。 4. 不要滥用 ：视口级的全局布局（页眉、主栏）仍该用媒体查询；容器查询解决的是\"同一组件多环境\"，两者不是替代关系。 与工具类的组合 Tailwind v4 已内置容器查询变体（ @container 、 @md/container: 一类），工程上更常见的落地是：给插槽类加 @container ，组件用变体写差异，避免手维护容器名。 小结 容器查询的真正价值不是新语法，而是 关注点归位 ：布局决策从\"页面知道组件在哪\"退回到\"组件知道自己多大\"。一张卡片学会看容器下菜碟之后，你在十个新场景里复用它都不需要再写第十套断点。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CSS",
        "响应式",
        "组件设计"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/css-custom-property-theme-tokens/",
      "url": "https://kairos.cn.mt/posts/css-custom-property-theme-tokens/",
      "title": "CSS 自定义属性做主题系统：一套变量驱动全站换肤",
      "summary": "用 RGB 三元组自定义属性构建分层主题变量，实现零重排的整站强调色切换，并把用户选择持久化到本地。",
      "content_text": "本文为通用技术教程，示例均可在任意现代浏览器中复现。 目标：换的不只是颜色，是\"角色\" 主题系统常见的做法是给每个组件写一套 .theme-dark .card { ... } 覆盖。组件一多就会失控。更好的抽象是把颜色拆成 角色（role） 而不是 位置 ： --accent-primary ：主强调色（链接、主按钮、聚焦环） --accent-primary-hover / --accent-primary-strong ：交互态 --surface- / --text- / --border- ：中性层 组件永远只消费角色 token。换主题时，换的是角色的取值，而不是任何组件样式。 关键技巧：RGB 三元组 + alpha 组装 如果直接存 #38e8c6 ，透明度变体（hover 蒙层、发光、软背景）就没法复用。把颜色存成 R G B 三个数字，使用时再组装透明度： :root { --accent-primary: 56 232 198; --accent-primary-soft: rgb(var(--accent-primary) / 0.12); --accent-primary-glow: rgb(var(--accent-primary) / 0.32); } .button { border-color: rgb(var(--accent-primary) / 0.55); box-shadow: 0 0 48px rgb(var(--accent-primary) / 0.09); } 这样一套 token 就覆盖了实色、软背景、光晕、边框等所有透明度层级，主题切换只需要覆盖一个三元组。 换肤：属性覆盖 + 级联 在 html 元素上用 data- 属性承载当前主题，CSS 里按属性选择器覆盖角色 token： html[data-accent='coral'] { --accent-primary: 255 122 144; --accent-primary-hover: 255 158 174; --accent-primary-strong: 214 74 98; } 注意两个细节： 1. 只覆盖角色，不覆盖派生值 。 --accent-primary-soft 这类派生 token 引用了 --accent-primary ，自定义属性是运行时求值的，基础值变了派生值自动跟随——这正是分层 token 的意义。 2. 选择器挂在 html 上而不是 :root 之外的元素 ，因为 :root 就是 html ，属性选择器与它同级特异性更高，且随 DOM 属性切换即时生效。 JavaScript 侧只需一行： document.documentElement.dataset.accent = 'coral'; 没有重排、没有组件遍历、没有 CSSOM 注入。Canvas 里也能吃到同一套颜色： const triplet = getComputedStyle(document.documentElement) .getPropertyValue('--accent-primary') .trim(); ctx.fillStyle = rgb(${triplet} / 0.8) ; 首帧不闪烁 从 localStorage 恢复主题的脚本必须 在首帧绘制之前 执行，否则用户会看到一次默认色闪现。把内联脚本放在 顶部、任何样式表生效之前： (() => { try { const { theme } = JSON.parse( localStorage.getItem('theme-prefs') || '{}', ); if (['coral', 'cream', 'green'].includes(theme)) { document.documentElement.dataset.accent = theme; } } catch { / 保持默认 / } })(); 使用 SPA 路由（或 Astro View Transitions）时还有一个隐藏利好： document.documentElement 跨导航持续存在， data-accent 属性不会因为换页丢失，无需在每个页面重复恢复。 无障碍校验 换肤改变了强调色，等于改变了对比度。上线前对每套主题过一遍： 主色上的正文/按钮文字（浅色主配深字时确认对比度 ≥ 4.5:1）。 彩色文字与其背景（提示：深底上的强调色文字看\"亮度\"而非\"色相\"）。 :focus-visible 轮廓在所有主题下清晰可辨。 四套主题值一起校验，比上线后再逐个修快得多。 小结 角色化 token + RGB 三元组 + 属性覆盖，三层叠加之后，\"全站换肤\"从一场组件大改造退化成三行 CSS。Canvas、Web Component、第三方样式都能通过同一套 token 对齐——这就是设计系统里\"单一事实来源\"的实际形状。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CSS",
        "设计系统",
        "主题",
        "可访问性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/dark-theme-contrast-pitfalls/",
      "url": "https://kairos.cn.mt/posts/dark-theme-contrast-pitfalls/",
      "title": "暗色主题下的对比度：为什么\"看着还行\"往往不合格",
      "summary": "暗色 UI 里低对比文字、彩色强调按钮、以及级联层导致的样式覆盖如何悄悄违反 WCAG 对比度，附定位与量化修复。",
      "content_text": "暗色主题好看，但它是无障碍对比度的高发区。很多配色在编辑器里\"看着挺清楚\"，用审计工具一测却全线不及格——而且不及格的方式很隐蔽。 标准是什么 WCAG 对比度要求（相对亮度比值）： 正文（< 18pt / 常规字重）：至少 4.5:1 ； 大文本（≥ 18pt 或 14pt 粗体）：至少 3:1 ； 图标、边框、表单控件等\"非文本 UI\"：至少 3:1 。 暗色主题最常见的失分点是\"低透明度灰字\"： rgba(255,255,255,0.5) 叠在 #0b0e14 上看着柔和，实测常只有 6.x:1 尚可；但一旦叠加在彩色卡片、或透明度再降一档到 0.4 ，很容易跌破 4.5。而 text/70 、 text/50 这类 Tailwind 半透明写法在深底上尤其危险。 一个隐蔽杀手：级联层覆盖 一次真实的暗色站审计里发现：\"订阅\"按钮文字对比度只有 1.73:1 （严重不达标），但组件源码写的是明确的 text-white 。根因是一条未分层的裸样式： a { color: inherit; } / 未分层 → 特异性 + 层级双高压过所有 utilities / 在 Tailwind 的 @layer utilities 下，一条 未分层 的 a { color: inherit } 会赢过分层工具类，让所有 静默变成继承来的低对比灰。修复不是加 !important ，而是把 html/body/a/:focus-visible 这类基础样式正确放进 @layer base ，让 utilities 层能正常覆盖它们： @layer base { a { color: inherit; } :root { / token 定义刻意保持未分层，确保组件能读取变量 / } } 暗色主题 + 半透明文字 + 级联层冲突 三者叠加，能制造\"肉眼觉得还行、工具判不合格、且不知道为什么\"的三重坑。 量化，而不是目测 不要凭眼睛。把计算式对比度纳入流程： function contrast(rgb1, rgb2) { const lum = ([r, g, b]) => { const a = [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 a[0] + 0.7152 a[1] + 0.0722 a[2]; }; const [l1, l2] = [lum(rgb1), lum(rgb2)].sort((a, b) => b - a); return (l1 + 0.05) / (l2 + 0.05); } 更省事的是直接拿计算样式喂审计： const cs = getComputedStyle(el); // 解析 cs.color 与逐层上溯的不透明背景，算对比度；或用 axe-core 的 color-contrast 规则 Lighthouse / axe 的 color-contrast 规则能自动化这件事，但前提是你真的去跑——很多\"暗色主题上线即不合格\"只是因为从没跑过。 暗色配色的实操建议 正文别用纯白 #fff （深底上刺眼），用 #e6e6e6 一类高灰阶，既舒适又轻松过 4.5:1； 强调色做 双档 ：一个用于大面积（对比适中），一个用于文字/图标的\"加强版\"（保证 ≥4.5，图标 ≥3）； 半透明文字设下限： muted 不低于 0.6 透明度，低于它改用实心低饱和灰； 边框/分隔线属于非文本 UI，≥3:1， rgba(255,255,255,0.08) 这种几乎不可见的分隔线其实不合规，需要提到 0.12–0.16。 小结 暗色主题的对比度不是\"选个深背景\"就完事，它要正向处理三件事：低透明度文字的量化、级联层不打架、强调色的可用性双档。把计算式对比度当成和\"构建通过\"同级的硬性门禁，\"看着还行\"就不会在真实用户（尤其低视力用户）那里变成\"根本看不清\"。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "可访问性",
        "暗色主题",
        "CSS",
        "对比度"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/force-directed-graph-minimal-canvas/",
      "url": "https://kairos.cn.mt/posts/force-directed-graph-minimal-canvas/",
      "title": "力导向图最小实现：在原生 Canvas 上写一个物理引擎",
      "summary": "不引库做出可拖拽的知识图谱星图：斥力、弹簧、向心力三股力量如何配平，缩放平移的坐标换算，以及 reduced-motion 下的静态降级。",
      "content_text": "本文为通用技术教程，全部代码可在浏览器控制台或单页项目中复现。 为什么\"自己写\"反而更轻 提到力导向图，第一反应是引 D3-force。但处理几十到几百个节点的规模时，核心物理只有三股力，O(n²) 的两两斥力在这个量级毫无压力。自研的好处是：能精确控制何时停帧（省电）、如何降级（reduced-motion）、以及坐标系统（与缩放平移共用一套换算）。 三股力的配平 每个 tick 依次施加： // 1) 斥力：任意两节点之间，距离越近推得越狠 for (let i = 0; i < nodes.length; i += 1) { for (let j = i + 1; j < nodes.length; j += 1) { const dx = a.x - b.x, dy = a.y - b.y; const distSq = Math.max(1, dx dx + dy dy); if (distSq > 90000) continue; // 远处的力忽略，性能与稳定双赢 const force = REPULSION / distSq; const dist = Math.sqrt(distSq); a.vx += (dx / dist) force; b.vx -= (dx / dist) force; } } // 2) 弹簧力：有边的节点被拉向理想长度 const force = (dist - SPRING LENGTH) STIFFNESS; // 3) 向心力：防止整张图飘走 node.vx -= node.x GRAVITY; 然后是积化和衰减： node.vx = DAMPING; // 阻尼，0.86 左右 node.x += node.vx alpha; 参数的量级感：斥力 2000–5000，弹簧长度 80–120，刚度 0.01–0.02，重力 0.001–0.002。斥力与弹簧的比值决定\"图是抱团还是散开\"，重力决定\"能不能被弹簧拉出凸包\"。 alpha：让图自己停下来 物理循环最大的工程问题是 CPU。解法是引入衰减因子 alpha： alpha = Math.max(0.02, alpha 0.996); 每帧位移乘以 alpha，运动幅度指数衰减；当所有节点速度足够小（或 alpha 触底）就 cancelAnimationFrame ， 循环彻底停止 。用户拖拽、缩放或改筛选时再把 alpha 抬回 0.55 重新加热——绝大多数时间 CPU 占用为零。 const frame = () => { tick(); draw(); if (alpha <= 0.03 && !dragging) { running = false; return; } requestAnimationFrame(frame); }; 拖拽、缩放、平移的坐标换算 Canvas 的世界坐标与屏幕坐标通过一个仿射变换关联： // 世界 → 屏幕：先平移到画布中心，再缩放 screenX = worldX scale + width / 2 + panX; // 屏幕 → 世界（命中测试用） worldX = (screenX - width / 2 - panX) / scale; 三个要点： 1. 命中测试必须用世界坐标 。把鼠标位置反算回世界系，再找半径 +6/scale 范围内最近的节点，才能保证任何缩放下手感一致。 2. 滚轮缩放要锚定光标 。缩放前后，光标下的世界点必须静止不动： const factor = Math.exp(-event.deltaY 0.0012); panX = cursorX - width / 2 - worldX nextScale; scale = nextScale; 3. 拖拽节点时把该节点标记为 pinned ，物理循环跳过它的位置积分，松手解除。节点被\"拎\"起来时，弹簧会自然把邻居拖过来——这是力导向图最有生命感的瞬间。 绘制层的两件事 DPR 适配 ： canvas.width = cssWidth devicePixelRatio ，再 setTransform(dpr, 0, 0, dpr, 0, 0) ，否则视网膜屏上全是锯齿。 视觉编码 ：半径 ∝ √degree，高连接节点画径向渐变光晕；标签只在\"选中、悬停、大节点、放大\"四种情况绘制，避免几百个文字糊成一团。 reduced-motion 与节能降级 prefers-reduced-motion 不是\"不画\"，而是\"不逐帧画\"： if (reducedMotion.matches) { for (let i = 0; i < 240; i += 1) tick(); // 同步跑完物理 draw(); // 画一帧静态终态 } 拖拽、缩放、平移是直接操作，应当保留——只是没有惯性动画。另外两个节能开关： IntersectionObserver 监听画布离开视口即停帧； document.hidden 时暂停。 停止条件清单 发布前逐条自检： [ ] alpha 触底或最大速度 < 阈值时，rAF 停止； [ ] 拖拽/平移期间循环保持运转，松手后重新衰减； [ ] 画布离开视口、标签页隐藏时停帧，回来时恢复； [ ] ResizeObserver 而非 window resize 适配容器尺寸； [ ] astro:before-swap （或路由卸载）时取消帧、断开所有 Observer。 小结 力导向图的\"高级感\"不来自物理公式的复杂度，而来自三件事：力量配平的参数手感、彻底停帧的工程纪律、以及直接操作（拖拽/缩放）的坐标换算。三件事都做对，一个 200 行的自研引擎就能在几十节点规模上取代整个图库。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Canvas",
        "数据可视化",
        "JavaScript",
        "动效"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/form-validation-three-layers/",
      "url": "https://kairos.cn.mt/posts/form-validation-three-layers/",
      "title": "表单校验的分层：HTML 属性、JS 增强与服务端最终判定",
      "summary": "邮箱订阅、评论这类表单如何做三层校验——原生属性守底线、JS 提升体验、服务端权威判定，避免\"前端校验即安全\"的常见误区。",
      "content_text": "一个提交邮箱的表单，\"对不对\"由谁说了算？正确答案是 三层各管一段 ，谁都不能替代谁。把它们混为一谈，就会同时得到糟糕的安全和糟糕的体验。 第一层：HTML 属性——零 JS 的底线 <input name=\"email\" type=\"email\" required maxlength=\"254\" autocomplete=\"email\" /> type=\"email\" ：浏览器原生拦截\"明显不是邮箱\"的输入，无需任何 JS； required ：空提交被表单原生阻止； maxlength ：把超长输入挡在客户端，防止无意义大包； autocomplete ：填表体验和自动填充的正确性。 这层的价值是 它在 JS 完全不可用时仍然工作 （渐进增强基线）。但它只能保证\"格式像\"，不能保证\"真实、合法、没滥用\"。 第二层：JS 增强——体验，而非安全 客户端脚本做的是\"更早、更准、更友好\"： 失焦即校验，实时红字提示（ aria-live 播报，别只靠变色）； 提交时 fetch JSON 接口，把结果渲染在行内（成功/限流/服务不可用分别给不同文案，且 透传服务端真实 message ）； 提交瞬间禁用按钮 + \"正在发送…\"，防重复提交； fetch 失败自动回退原生整页提交（增强挂了退回第一层）。 关键认知：这一层是给正常用户用的，不是防坏人用的。 任何人关掉 JS 或直接用 curl 打接口，第二层形同虚设。把\"邮箱格式校验\"只写在这一层，等于没写。 第三层：服务端——唯一权威 服务端必须假设前两层都不存在，独立完成全部判定： // 1) 再次校验格式（不信任客户端） if (!isValidEmail(email)) return respond(request, 'invalid', {}, 400); // 2) 限流：按 IP + 账号维度，防刷 if (await isRateLimited(...)) return respond(request, 'rate-limited', {...}, 429); // 3) 业务规则：topic 白名单、去重、幂等 // 4) 数据落库/投递 服务端还负责定义 错误语义 ：哪些是\"用户可修复\"（格式错、已订阅），哪些是\"用户不可修复\"（服务暂不可用）。前端要如实透传这个区分——把 503 翻译成\"邮箱格式不对\"是真实发生过的反例（详见踩坑《订阅接口 503 被前端翻译成了\"邮箱格式不对\"》）。 三层如何协作而不互相甩锅 | 关注点 | 归属层 | 反例 | | --------------- | ------------------ | ---------------------------------- | | 必填/格式像不像 | HTML | 只做 JS 校验 → 关 JS 就绕过 | | 即时友好反馈 | JS | 只在服务端返回整页错误 → 体验差 | | 真实合法性 | 服务端 | 信任客户端 valid=true → 可被伪造 | | 防滥用/限流 | 服务端 | 只做前端\"按钮防连点\" → 脚本秒绕过 | | 错误分类与文案 | 服务端定、前端透传 | 前端自己瞎猜错误原因 | 一个常被忽略的点：可访问的校验 错误提示不能只靠红色——色盲用户看不见。正确做法： 出错字段 aria-invalid=\"true\" + aria-describedby 指向错误文案； 错误文案容器 role=\"alert\" 或 aria-live=\"polite\" ，让读屏器播报； 提交失败后焦点移到第一个错误字段。 原生 :invalid 伪类也能做视觉标记，但同样要配文本，不能只变色。 小结 表单校验不是\"要不要加前端校验\"的二选一，而是三层的明确分工：HTML 守无 JS 底线、JS 提升正常用户体验、服务端做不可绕过的权威判定，并把错误语义交给前端如实透传。任何一层的缺失，都会以\"安全洞\"或\"体验坑\"的形式，在你看不到的地方兑现。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "表单",
        "校验",
        "安全",
        "UX"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/json-payload-bridge-server-browser/",
      "url": "https://kairos.cn.mt/posts/json-payload-bridge-server-browser/",
      "title": "一份配置，两个世界：服务端模块如何喂给内联脚本",
      "summary": "用 JSON script 载荷把服务端模块里的配置、规则表传给浏览器内联脚本，避免在 Astro 项目中把同一份数据手抄两遍。",
      "content_text": "一个反复出现的漂移现场 Astro 页面有两种脚本：打包模块脚本（可 import ）与 is:inline 脚本（原样输出、不打包）。后者常见于必须在首屏前执行、或刻意避免 hydration 时序问题的场景——主题应用、防闪烁脚本、页面级增强。 麻烦从这里开始：很多运行参数其实定义在 TypeScript 模块里（动效配置、成就规则表、工具示例数据），而 is:inline 脚本碰不到模块系统。于是同一份数据出现两种实现： // src/lib/achievements.ts —— 事实 A { id: 'streak-3', label: '三日之约', condition: { kind: 'streakDays', atLeast: 3 } } // 某页面 is:inline 脚本 —— 事实 B（手抄） { id: 'streak-3', label: '三日之约', test: (s) => s.streakDays >= 3 } 两处手抄迟早漂移：改了 lib 忘了脚本，或反过来。更隐蔽的是语义漂移——字段从 threshold: 3 改成 atLeast: 3 ，另一处没跟上，构建照样通过。 模式：结构化数据进 payload，函数留在消费端 服务端 setup 里 import 配置模块，把 可序列化部分 写进 JSON script 标签；浏览器端从 DOM 读取并解释。以成就规则为例，规则表在 lib 里定义成纯数据： // src/lib/achievements.ts export type AchievementCondition = | { kind: 'completedPosts'; atLeast: number } | { kind: 'totalSeconds'; atLeast: number } | { kind: 'streakDays'; atLeast: number } | { kind: 'flag'; flag: string }; export const achievementBadges = [ { id: 'first-read', label: '初次点亮', description: '完成 1 篇文章阅读', condition: { kind: 'completedPosts', atLeast: 1 }, }, // … ]; 页面模板注入（ set:html 对 script 内容是安全的，JSON 中已无用户输入）： <script type=\"application/json\" id=\"achievement-badge-data\" is:inline set:html={JSON.stringify(achievementBadges)} 内联脚本负责解释——函数不能进 JSON，但解释器只写一份： const badgeRules = JSON.parse( document.querySelector('#achievement-badge-data')?.textContent || '[]', ).map((badge) => ({ ...badge, test: (state) => evaluateCondition(badge.condition, state), })); 关键约束： payload 里是 kind/atLeast 这样的判别式数据， switch 解释器只有内联脚本一处 。lib 修改规则表（阈值、文案、新增徽章）无需动任何页面；只有新增条件种类时才需要在解释器里加一个 case——这正是一个显式、可测试的扩展点，而不是隐式的手抄同步。 与 define:vars 的区别 define:vars 是变量插值：把值 展开成源码文本 塞进脚本。它在模板阶段生成形如 const cfg = {...} 的顶层声明，在客户端路由（多次执行同一脚本体）下容易触发重复声明错误，调试时看到的产物也充满插值痕迹。JSON script 方案则把数据与代码物理分离： 脚本体是稳定的 IIFE，数据永远来自 textContent ，客户端路由下重新初始化只是重新读一次 DOM； 数据可以先在浏览器 DevTools 里直接查看，不需要在编译产物里找插值； 类型检查发生在服务端（setup 的 TS），JSON 结构错误在构建期就暴露。 两者都合理，但\"传数据\"这件事上，payload 方案在动态化页面里更稳。 工程细节清单 1. fallback 要有，且要小。 payload 节点丢失或被篡改时，脚本应回退到\"最保守可用\"的内置子集，而不是整段功能死亡。 2. 给 JSON script 固定 id 。 便于 DevTools 检查，也便于测试里断言注入成功。 3. 解释器函数保持纯函数。 上面 evaluateCondition(condition, state) => boolean 是纯函数，可直接在 Node 层为 lib 的数据驱动版本写单测。 4. 别把秘密放进 payload。 JSON script 会原样出现在 HTML 源码里，只放本就公开的数据；页面内联脚本读的是 DOM，不是运行时权限。 小结 \"服务端知道，浏览器也要知道\"的数据，优先设计成 可序列化的判别式数据 + 单一解释器 。lib 是事实源，HTML 是事实快照，页面脚本只是快照的阅读者。手抄一遍看似省五分钟，漂移一次赔一整个重构。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "前端工程",
        "数据流",
        "设计模式"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/lighthouse-scores-to-fixes/",
      "url": "https://kairos.cn.mt/posts/lighthouse-scores-to-fixes/",
      "title": "Lighthouse 分数怎么读：从类别分到可执行的修复",
      "summary": "Performance/A11y/Best-Practices/SEO 四个类别分背后的审计项如何映射到具体修复：对比度、表单标签、控制台错误与指标类洞察的定位方法。",
      "content_text": "本文为通用技术教程，示例来自可公开验证的审计行为，不针对任何具体站点数据。 类别分是平均数，修复要看审计项 Lighthouse 的四个类别分（Performance / Accessibility / Best Practices / SEO）各自由一组加权审计项平均而来。类别分 92 意味着\"有少量审计项没过\"，而 每一个未过审计项都是一条可执行的修复指令 。 读报告的正确姿势不是盯着分数叹气，而是：按 score < 1 过滤审计项，逐条把 details.items 里的元素定位出来。 可访问性：三个高频杀手 1. color-contrast（对比度） Lighthouse 用计算样式算前景/背景对比度，阈值 4.5:1（大字 3:1）。常见的三种来源： 语义 token 没接到元素上（比如按钮文字继承了父级的暗淡色，而不是使用反色 token）； 透明度叠加： opacity: 0.6 的文字会让实际对比度跌出阈值； 彩色文字落在浅色背景上，只顾了品牌色没顾可读性。 修复时注意\"继承链\"：一个按钮显示错色，问题可能出在三层之外的父元素。 2. label（表单控件无标签） 每个可交互的 / / 都需要可访问名称，来源优先级：显式 > 隐式包裹 label > aria-label > aria-labelledby 。 Markdown 渲染的任务清单（GFM task list）是个隐蔽重灾区—— - [ ] 会渲染成 没有关联 label 的复选框 。对纯展示用途的清单，处理方式是禁用并补名称： document .querySelectorAll('.task-list input[type=\"checkbox\"]') .forEach((checkbox, index) => { checkbox.disabled = true; checkbox.setAttribute('aria-label', 清单项 ${index + 1} ); }); disabled 让它退出 Tab 序与表单提交，审计也不再检查它。 3. label-content-name-mismatch（可访问名与可见文本脱节） 给控件加 aria-label 本意是更好的无障碍描述，但如果可访问名 不包含 可见文本，语音控制用户说\"搜索\"时可能匹配不到那个名字里写的是\"全站检索\"的按钮。 规则：可访问名必须包含可见文本，额外说明放后面： 搜索 搜索 Best Practices：控制台错误 errors-in-console 常年躺在报告里没人管，因为它\"不影响功能\"。但它是四类问题的一站式集合： 资源 404（典型：favicon、懒加载图片、写错的预加载链接）； 未捕获的 Promise rejection； 已废弃 API 警告； CSP 违规报告。 修复优先级最高的往往是最无聊的：站点从未配置 favicon，每个访客的浏览器都在请求一个 404。一个 3 行的 SVG 图标加一行 就能清掉整页每访客一次的报错，同时让书签栏有脸见人。 Performance：区分\"指标\"与\"机会\" 性能部分的审计项分两类，处理方式完全不同： 指标（metrics） ：LCP、TBT、CLS、Speed Index。它们有数值阈值，修复靠优化手段——预加载关键资源、拆分长任务、给媒体预留尺寸。 洞察（insights） ：render-blocking-resources、unminified-javascript、uses-text-compression 这类\"机会项\"。它们指向确定性的工程动作： 渲染阻塞：把非关键 CSS 异步化、脚本加 defer ； 压缩：确认服务器对 JS/CSS/JSON 开了 gzip 或 brotli（自部署站点最常见的失分项是忘了给 .json 开压缩）； 压缩混淆：构建管线里关了 minify 就等于白送几百毫秒。 把审计变成回归门禁 单次跑分是行为，门禁才是习惯。Lighthouse CI 的断言机制可以把每个审计项变成构建门禁： { \"ci\": { \"assert\": { \"assertions\": { \"categories:performance\": [\"error\", { \"minScore\": 0.9 }], \"categories:accessibility\": [\"error\", { \"minScore\": 0.9 }], \"cumulative-layout-shift\": [\"error\", { \"maxNumericValue\": 0.1 }] } } } } 注意事项只有一条：跑分环境必须可复现（固定 Chrome 版本、固定视口、固定节流方式），否则分数的波动会淹没回归信号。 小结 Lighthouse 报告的价值不在那个大分数，而在 score < 1 的审计项列表——每一条都是带着定位信息的修复工单。对比度看继承链，表单看可访问名，控制台 404 顺手清，性能分清\"指标\"与\"机会\"。四条走完，类别分会自己回来。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Lighthouse",
        "性能",
        "可访问性",
        "Web 工程化"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/local-first-tool-privacy-architecture/",
      "url": "https://kairos.cn.mt/posts/local-first-tool-privacy-architecture/",
      "title": "本地优先的工具站设计：让输入永远不离开浏览器",
      "summary": "浏览器端开发者工具的隐私架构怎么做：无后端处理、状态留在本地存储、错误不外泄，以及如何用构建期索引替代服务端搜索。",
      "content_text": "本文讨论可公开验证的通用设计方法，所有示例均为演示场景。 为什么\"本地处理\"值得当作架构承诺 开发者工具经常要处理敏感内容：一段含 token 的 JWT、一个带内网地址的配置 JSON、一条从日志里抠出来的报错。如果这类输入会被发送到服务端，工具的每一次粘贴都是一次信任消耗。 本地优先（local-first） 的承诺很简单：输入在浏览器里产生，在浏览器里处理，在浏览器里消失。它不是一句文案，而是一组可以逐条验证的工程约束。 约束清单 一个合格的本地工具页，至少要满足这五条： 1. 没有上传路径 。处理逻辑只存在于页面脚本中，页面不包含任何把用户输入写进 fetch body 的代码。 2. 持久化只针对偏好，不针对内容 。字号、主题这类偏好可以进 localStorage ；用户粘贴的内容最多进 sessionStorage （关标签即失效），或者干脆只放在内存里。 3. 日志脱敏 。前端 console 与埋点里不出现原始输入。最稳妥的做法是工具页根本没有埋点。 4. 错误信息不携带数据 。解析失败时提示\"无法解析\"而不是把输入原样打出来——错误提示本身也可能成为泄露面。 5. 可以断网使用 。这是验证前四条最直接的方式：拔掉网络，工具应该一切照常。 构建期索引：搜索也可以不出服务器 站内搜索通常需要一个\"服务端\"。但内容量在几百条以内的站点，完全可以在 构建期 把公开内容的标题、摘要、标签导出成静态 JSON： // 构建脚本（Node）：扫描内容集合 → 生成 search-index.json const items = collections.flatMap((collection) => collection.entries .filter((entry) => !entry.draft) .map((entry) => ({ type: collection.name, title: entry.title, href: entry.href, excerpt: entry.description, tags: entry.tags, })), ); await writeFile('public/search-index.json', JSON.stringify({ items })); 页面端用模糊匹配（子序列打分即可）在内存里完成检索。这样做的额外收益： 草稿天然隔离 ：只导出非草稿内容， draft: true 的条目从源头就不会进入索引，避免了\"草稿泄漏到搜索\"这一整类问题。 无查询成本 ：没有搜索服务，也就没有搜索服务的限流、日志和故障。 索引即产物 ：索引随构建生成、随部署更新，\"内容改了索引没更新\"这一类陈旧问题被流水线消灭。 需要注意的边界：索引里只能放 公开元数据 （标题、摘要、标签），不要把正文全文塞进去——索引 JSON 是任何访客都能下载的静态文件。 用 DevTools 快速复核 发布前可以做一个快速自查，在 DevTools 的 Network 面板里： 1. 打开工具页，粘贴一段测试内容，执行所有操作。 2. 过滤 XHR/Fetch 请求：除页面自身资源外，不应该出现任何携带输入内容的请求。 3. 检查 localStorage / sessionStorage ：确认只有偏好键，没有内容键。 4. 断网重测一遍核心功能。 四步都干净，\"输入不出浏览器\"才算成立。 小结 本地优先不是少写了后端，而是把信任模型改了：用户不需要相信服务器会删除数据，因为数据从未到达服务器。对个人开发者的工具站来说，这既是伦理选择，也是实打实的架构简化——没有上传路径，就没有为上传路径负责的成本。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "隐私",
        "工具设计",
        "前端架构",
        "JavaScript"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/localstorage-schema-versioning/",
      "url": "https://kairos.cn.mt/posts/localstorage-schema-versioning/",
      "title": "localStorage 的 schema 版本化：命名、迁移与可回退",
      "summary": "浏览器本地存储跨版本演进的最佳实践——统一命名、读写分离、迁移而非破坏、提供一键清理，避免旧用户数据变成幽灵 bug。",
      "content_text": "个人站点上的\"本地优先\"功能（书签、阅读进度、主题偏好、成就）几乎都住在 localStorage 里。它没有数据库那样的迁移工具，但同样需要版本策略——否则老访客的旧数据会在新版本里制造无法复现的 bug。 一、统一命名：前缀 + 命名空间 + 版本 一个站点的所有 key 应当有共同前缀，冒号分层，末尾带 schema 版本： myblog:reading-achievements:v1 myblog:accent-theme:v1 myblog:bookmark: 对比反面教材：同一站点里混用 myblog-bookmark-favorites （连字符、无版本）和 myblog:recent-searches:v1 。前者难 grep、难批量清理、无法判断格式版本。统一命名看似小事，却决定了\"一键清除本站数据\"\"跨标签同步\"\"旧数据迁移\"这些能力能不能廉价实现。 二、迁移而非破坏：读时归一化 不要在用户下次访问时直接丢弃或崩溃于旧格式。最稳的是 读时迁移 ： const NEW KEY = 'myblog:bookmark-favorites:v1'; const OLD KEY = 'myblog-bookmark-favorites'; function readFavorites() { try { // 旧 key 存在且新 key 未写 → 一次性搬迁，再删旧 key if (!localStorage.getItem(NEW KEY) && localStorage.getItem(OLD KEY)) { localStorage.setItem(NEW KEY, localStorage.getItem(OLD KEY)); localStorage.removeItem(OLD KEY); } const raw = localStorage.getItem(NEW KEY) || '[]'; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; // 隐私模式/配额/坏数据：降级为空，绝不抛穿到 UI } } 要点： 迁移是 幂等 的（新 key 已存在就跳过），重复执行安全； 解析失败一律 catch 到默认值—— localStorage 在隐私模式可能抛异常，坏 JSON 可能来自半次写入； 结构升级（v1→v2）用同样套路：读 v1，转换，写 v2，保留 v1 一个版本周期以便回滚。 三、写操作要有超时与配额意识 localStorage.setItem 在超出配额（多数浏览器约 5–10MB）时抛 QuotaExceededError 。任何\"保存\"都应包在 try/catch 里，失败时至少静默降级、最好给用户可见提示。把持久化视为 best-effort，功能正确性不能依赖它成功。 四、必须提供\"清除我的数据\"入口 本地存储对用户不可见，一个诚实的站点应提供一键清除。实现很直接——遍历删除带统一前缀的 key： function wipeAll(prefix = 'myblog') { const doomed = []; for (let i = 0; i < localStorage.length; i += 1) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) doomed.push(k); } doomed.forEach((k) => localStorage.removeItem(k)); } 统一前缀在这里显出价值：没有它，你无法安全地\"只清本站数据\"。配合二次确认弹窗，构成对个人数据的最低尊重。 五、跨标签页一致性 localStorage 改动会触发其他同源标签页的 storage 事件。主题、偏好类状态应监听它，保证多标签不各说各话： window.addEventListener('storage', (e) => { if (e.key === 'myblog:accent-theme:v1') applyAccent(e.newValue); }); 注意 storage 事件 不在改动的那个标签页自身触发 ，别把它当本标签页的更新通知用。 小结 localStorage 的\"本地优先\"是隐私与离线体验的红利，但也把数据治理责任从服务端搬到了浏览器。一条命名规范、一次读时迁移、一个清除入口、一个跨标签同步，就构成了个人站本地存储最小的可持续性方案——它保证三年后你改了这个存储结构，老访客回来时看到的是无缝升级，而不是丢失的书签和一个\"只有我能复现\"的 bug。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "localStorage",
        "前端工程",
        "数据迁移",
        "可用性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/mobile-overflow-wrap-triad/",
      "url": "https://kairos.cn.mt/posts/mobile-overflow-wrap-triad/",
      "title": "长内容换行三件套：inline code、nowrap 胶囊与时间线轨道",
      "summary": "移动端横向溢出常由三类\"固有宽度\"内容撑破容器——无空格长代码、white-space:nowrap 标签、未设 minmax(0) 的网格轨道，逐一给出修复。",
      "content_text": "移动端\"页面能横向拖\"几乎从不是布局本身错了，而是某个叶子内容拒绝收缩，把固定宽度一路上溯到根。本节内容是对一次全站横向溢出扫描（101 条路由、360px 视口）中定位到的三类真实成因的归纳。 一、无空格的长 inline code 正文里行内代码 document.querySelector(sel).match(...) 或 ['pending','confirmed','invalid',...] 这类 没有可断点 的长串， word-break 默认按词处理、英文词内部不折行，直接顶破容器。 修复：只对\"非块级代码\"开启收缩，避免误伤代码块本身的横向滚动。 .article-content :not(pre) > code { overflow-wrap: anywhere; / 允许在任意字符处断行 / word-break: break-word; } 块级 pre 保留 overflow-x: auto （代码要横向滚动而非折行），二者策略相反，所以选择器必须区分： :not(pre) > code 。 二、white-space: nowrap 的胶囊/徽章 时间线、标签云里的\"上一篇：某某标题…\"链接常写 white-space: nowrap 配省略号。但 省略号生效的前提是元素能被压缩 ——flex/grid 项默认 min-width: auto ，等于\"不许小于内容宽度\"，nowrap + auto 最小尺寸 = 永不触发 ellipsis，反而撑宽整行。 .series-nav-link { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; min-width: 0; / 关键：允许收缩，ellipsis 才会真正生效 / } 配合容器 grid-template-columns: minmax(0, 1fr) ，胶囊在窄屏自然截断成 下一篇：Astro 内容系统如… 。 三、网格轨道没设 minmax(0) grid 单列若写成隐式列（或直接 display:grid 只给 gap ），其列宽最小值默认 auto ，会被任一子项的固有宽度顶开——上一节 select 撑破整页即此类。通用防御： .container { grid-template-columns: minmax(0, 1fr); } 凡是\"子项可能含不可收缩内容\"（select、长 code、nowrap 标签、图片、表格）的网格/flex，轨道一律 minmax(0, …) ，子项一律补 min-w-0 。 定位方法：谁在撑？ 不要靠肉眼。程序化扫描三步： // 1) 测页面是否横向溢出 const overflow = document.documentElement.scrollWidth - window.innerWidth; // 2) 若溢出，找\"右侧越界且祖先无裁剪\"的最内层元素 document.querySelectorAll('body ').forEach((el) => { const r = el.getBoundingClientRect(); if (r.right <= window.innerWidth + 4) return; let a = el.parentElement, clipped = false; while (a) { const ox = getComputedStyle(a).overflowX; if (['auto', 'scroll', 'hidden', 'clip'].includes(ox)) { clipped = true; break; } a = a.parentElement; } if ( !clipped && ![...el.children].some( (c) => c.getBoundingClientRect().right > window.innerWidth + 4, ) ) { console.log('元凶:', el); // 叶子 + 未被祖先裁剪 = 真凶 } }); \"祖先裁剪\"的过滤很关键—— pre 本身 overflow-x:auto ，其内代码右边界超屏是 正常滚动 不是溢出；只有既越界、祖先又全不裁剪的最内层元素，才是需要修的点。 小结 横向溢出的修复从不是\"加个 overflow:hidden 盖住\"，而是找到那个拒绝收缩的叶子，给它一条明确的收缩契约：允许断字、允许压窄、轨道留下限。三类成因（长 code / nowrap 标签 / 默认网格轨道）覆盖了绝大多数线上所见即所得的\"手机能横着拖\"。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CSS",
        "响应式",
        "溢出",
        "排版"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/progressive-enhancement-checklist/",
      "url": "https://kairos.cn.mt/posts/progressive-enhancement-checklist/",
      "title": "渐进增强不是口号：把\"关掉 JS 也能用\"落成检查项",
      "summary": "以静态优先博客为例，给出每个交互组件的无 JS 基线设计法——原生表单、真实链接、服务端渲染内容，把渐进增强从信念变成可验收的清单。",
      "content_text": "\"渐进增强\"常被当作装饰性口号：说了，但没人验收。它的可操作定义其实很简单—— 禁用 JavaScript 后，站点的核心任务是否仍可完成？ 本文把它拆成一份能逐条勾选的检查清单，以内容站为例。 基线：无 JS 时，什么必须仍然成立 1. 所有内容可读 ：正文由服务端渲染进 HTML，不靠前端 fetch 填充（用 fetch 自取数据再注入 DOM 的做法，JS 一关就是白页——这是\"SSR 页面 fetch 自己静态文件\"踩坑的孪生问题）。 2. 导航可用 ：站内跳转是真实的 ，不是 onclick 里写 router.push 。增强脚本可以在点击时拦截走 SPA，但 href 本身必须是有效目标 。 3. 表单可提交 ：搜索、订阅、评论用原生 提交到服务端端点，JS 只是把体验升级成\"行内反馈\"。服务端端点要能独立处理并返回结果页。 4. 状态可见 ：收藏、进度这类增强若依赖 JS，无 JS 时应退化为\"该功能不显示\"，而不是\"显示一个坏掉的交互\"。 把增强做成\"加法\"而非\"替换\" 一个正确的订阅表单是这样分层的： 发送确认邮件 // 增强：拦截提交，改成 fetch + 行内反馈，失败则回退原生提交 form.addEventListener('submit', async (e) => { e.preventDefault(); try { const res = await fetch(form.action, { method: 'POST', headers: {...}, body: ... }); // 渲染成功/错误到 aria-live 区 } catch { form.submit(); // 网络/payload 失败：退回原生整页流程 } }); 关键在最后那行 form.submit() —— 增强路径失败时，交回基线路径 ，而不是把用户困在一个\"点了没反应\"的按钮上。渐进增强的精髓不是\"有 JS 更好\"，而是\"JS 挂了的瞬间自动降级回可用态\"。 内容渲染的\"服务端为准\" 静态优先框架（如 Astro）默认把内容构建进 HTML，天然满足基线。风险来自\"过度客户端化\"：为了动效把整页数据塞进 fetch + useState ，等于主动放弃无 JS 基线。判断标准—— 内容属于 信息 （文章、列表、导航）→ 服务端渲染，客户端只加动效； 内容属于 即时交互态 （输入校验反馈、拖拽结果）→ 才交给客户端。 把第一条守住的站点，JS 全关仍能当文档站用；把第二条误当第一条的站点，JS 一挂就白屏。 验收：一份可勾选的清单 每个交互组件上线前，在 DevTools 禁用 JS 逐条验证： [ ] 页面主体内容显示完整，无空白占位区 [ ] 每个导航项是可回车跳转的真实链接 [ ] 每个表单能用原生提交得到一个正确结果页 [ ] 报错路径不依赖 JS 才能看到提示（服务端渲染的错误页存在） [ ] 关键 SEO 文本（标题/描述/正文）在 view-source 里可见，非 JS 注入 这份清单跑通，\"渐进增强\"就从信念变成了可回归的工程事实。 小结 真正的健壮不是\"什么都能用\"，而是\"最坏情况下仍能用\"。把无 JS 基线设成验收项，你同时赚到的还有：搜索引擎友好、无障碍友好、弱网友好、以及\"某个脚本 404 了全站不崩\"的韧性。渐进增强从来不是怀旧，是用最便宜的方式买最贵的保险。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "渐进增强",
        "无脚本",
        "SSR",
        "可访问性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/rate-limit-aware-frontend/",
      "url": "https://kairos.cn.mt/posts/rate-limit-aware-frontend/",
      "title": "让前端\"看得懂\"限流：429/503 的退避与文案分工",
      "summary": "当接口返回 429 或 503，前端该重试、等待还是提示？讲清按状态码分流、指数退避、Retry-After 利用与\"用户可修复 vs 不可修复\"的文案设计。",
      "content_text": "个人站点的评论、点赞、订阅接口都会限流。多数前端把它们一律渲染成\"操作失败，请重试\"，于是用户对着一个\"再试也没用\"的按钮反复点击。限流响应的正确处理，是按状态码分流，而不是统一报错。 先看服务端给了什么信号 一个设计良好的接口，不同失败会给不同状态码 + 可选的重试提示： | 状态码 | 含义 | 前端应做 | | ------ | ---------------------------- | ---------------------------------------------------- | | 400 | 输入无效（用户可修复） | 提示改输入， 不 自动重试 | | 429 | 触发限流（等待可恢复） | 读 Retry-After ，禁用提交并倒计时，可选自动退避重试 | | 503 | 服务暂不可用（用户无法修复） | 提示稍后再试 / 换渠道， 不 误导用户改输入 | Retry-After 头（429/503 常带）是服务端明确告诉你\"多久后再来\"： HTTP/1.1 429 Too Many Requests Retry-After: 30 分流处理 async function submit(payload) { const res = await fetch('/api/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(8000), // 关键：超时兜底，别让它无限挂着 }); const data = await res.json().catch(() => ({})); if (res.status === 429) { const wait = parseInt(res.headers.get('Retry-After') || '30', 10); startCountdown(wait); // 禁用按钮 + 倒计时，防继续点 return show('操作太频繁，请 ' + wait + ' 秒后再试。'); } if (res.status >= 500) { return show(data.message || '服务暂时不可用，请稍后重试。'); // 不背锅给用户输入 } if (!res.ok) { return show(data.message || '提交的内容似乎有问题，请检查。'); // 4xx：用户可修复 } return ok(data); } 要点： 1. signal: AbortSignal.timeout(...) 是第一道防线 ——没有它，接口一卡，按钮永久停在\"正在提交…\"，比任何文案错误都伤体验； 2. 透传服务端 message 优先于前端硬编码 （服务端最懂真实原因）； 3. 429 要消费 Retry-After ，禁用 + 倒计时，把\"你别再点了\"变成可见的确定信息； 4. 5xx 不翻译成输入错误 ——这是真实踩过的坑（详见踩坑《订阅接口 503 被前端翻译成了\"邮箱格式不对\"》）。 要不要自动重试？ 幂等请求（GET 拉评论数）+ 5xx/网络错：可 指数退避 重试，上限 2–3 次； async function withBackoff(fn, retries = 3) { for (let i = 0; i < retries; i += 1) { try { return await fn(); } catch (e) { if (i === retries - 1) throw e; await new Promise((r) => setTimeout(r, 300 2 i + Math.random() 100), ); // 加抖动 } } } 非幂等提交（POST 点赞/订阅）： 不要盲目自动重试 ，除非服务端支持幂等键。重复点击 + 自动重试 = 重复提交。宁可用户手点一次明确的\"重试\"。 退避要加 抖动 （jitter），否则多客户端会在同一时刻同步重试，把刚恢复的服务再打倒。 文案的三分法 给用户的错误信息按\"下一步能做什么\"分类，而不是按技术原因分类： 你能修的 ：说清楚改哪里（\"昵称不能为空\"），聚焦到出错字段； 等会儿就行的 ：给确定时间锚点（\"30 秒后可再试\"），别写\"稍后再试\"这种无信息量话； 你修不了、也不该你扛的 ：诚实说明是服务端问题 + 给替代渠道（\"也可用 RSS 关注\"），绝不暗示是用户操作失误。 小结 限流不是一个错误码，是一组需要区别对待的信号：超时要有 AbortSignal 、429 要读 Retry-After 并禁用倒计时、5xx 要如实归因、幂等才可退避重试。把\"操作失败请重试\"拆成这三类可执行提示，接口的限流机制才第一次真正为用户所用，而不是只服务于服务端自保。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "API",
        "前端",
        "错误处理",
        "限流"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/release-runtime-gate-automation/",
      "url": "https://kairos.cn.mt/posts/release-runtime-gate-automation/",
      "title": "发布前的最后一道程序：用脚本自动化\"人工走查\"",
      "summary": "把死链扫描、禁用术语检查、关键路由冒烟固化成可在 CI 与发布前重复执行的运行时门禁脚本，替代不可靠的手工点击。",
      "content_text": "手工验收的悖论：你知道哪些页面值得点开看，所以你每次都只看那些页面；而出问题的从来是另一些。把发布前走查写成脚本之后，它每天看的页面比人多，而且从不疲劳。 什么样的检查适合写进\"运行时门禁\" 单元测试证明函数正确，构建证明编译可行，但还有一类问题只有 跑起来的站点 才能回答： 1. 每个 URL 是否返回预期状态 （导航目标、sitemap 条目、索引里的 href）； 2. 公开页面是否泄露内部词汇 （工程术语、阶段状态、密钥样式的字符串）； 3. 站内链接是否存在死链 （渲染后的 HTML 里抽 href，逐个请求）； 4. 核心接口冒烟 （搜索索引 JSON 可读、Feed 合法、健康检查 200）。 这些检查的共同点：规则简单、覆盖面越大越值钱、人工执行必漏。 骨架：从枚举到断言 一个可复用的运行时门禁脚本大致四层： // 1) 目标来源：sitemap + 页面内链 + 搜索索引 href，合并去重 const targets = collectFromSitemap(); targets.push(...extractHrefs(htmlOf('/'))); // 首页是最大入口分发器 for (const url of new Set(targets)) { const res = await fetch(BASE + url, { redirect: 'manual' }); if (![200, 301, 302, 307].includes(res.status)) report.dead.push([url, res.status]); } // 2) 禁用词扫描：公开路由的正文/标题/meta 命中即失败 const forbiddenTerms = [/MVP/i, /token/i, /内部阶段/, /staging 密钥/]; for (const route of publicRoutes) { const html = await getHtml(route); const hits = matchForbidden(html, { skipCode: true }); ... } // 3) 行为断言：关键路由必须含某些结构 expectHtml('/subscribe/status', /查询订阅处理结果/); // idle 态 expectHtml('/posts/', /文章/); // 4) 产物一致性：索引条目的每个 href 都在站点可达集合内 三个容易写歪的地方 其一：扫描范围里，\"渲染后的 HTML\"才算数。 直接读 public/ .json 或源码字符串会漏掉模板拼接出来的内容；起服务、发请求、解析响应体，才覆盖真实产物。本项目在 CI 与发布机各跑一次：CI 跑构建产物预览，发布机跑真实域名。 其二：禁用词扫描要有排除层，但不能有静默层。 命中 token 在代码块里是正常的（本文自己就出现过多次），所以扫描要跳过 / 内容；但 标题、摘要、导航文案命中即失败 ——这条曾经真实拦下过：新文章标题撞上了禁用术语，改的是文章，不是放宽规则。规则可以讨论粒度，不可以为了绿灯整体关闭。 其三：链接提取器要认识\"活的 URL\"。 现代页面里有大量 JS 注入的跳转（命令面板选项在插入 DOM 后才 setAttribute('href', …) ），把脚本源码里的字面量当链接会误报死链。两种收敛方式：只提取渲染后 DOM 的 a[href] （需要真实浏览器），或者像本项目一样把注入写法改成\"先插入后赋值\"，让静态提取天然看不到占位符。 让门禁可复跑、可追溯 失败输出结构化： { deadLinks, publicCopyHits, routeFailures, pass } ，一份 JSON 同时给人看和给 CI 断言； 报告落盘并归档（ /tmp 或 artifact 目录），复盘时能回答\"当时到底绿没绿\"； 退出码诚实：任何 fail 都非零退出；环境缺依赖时明确打印阻塞原因，而不是 || true 吞掉。 边界：门禁不能替你判断的事 脚本回答\"有没有死链/有没有违禁词/接口通不通\"，不回答\"这页好不好看、这篇文案有没有说服力\"。视觉走查、内容审核、交互手感仍然是人的工作——自动化的意义恰恰是把机械部分压到零，让人的注意力只花在机器做不了的事上。 小结 把发布前走查写成四件套：枚举 URL → 探状态 → 扫词汇 → 验索引一致性。它拦不住所有问题，但它拦住的每一个，都是你\"本来不会去点的那页\"。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CI",
        "质量保障",
        "发布流程",
        "自动化测试"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/security-headers-self-hosted-checklist/",
      "url": "https://kairos.cn.mt/posts/security-headers-self-hosted-checklist/",
      "title": "自部署站点的响应头底线：HSTS、CSP 与缓存分层怎么配",
      "summary": "面向 Node standalone + Nginx 反代的个人站点，梳理一份可落地的安全响应头清单：各头的作用、顺序、坑与验证方法。",
      "content_text": "安全响应头是最便宜的安全投资：一次配置，全站生效，且完全可以在无侵入的前提下逐步收紧。本文以\"Node 应用 + Nginx 反向代理\"的个人站点拓扑为例，给出一份经过验证的头清单与配置顺序。 分层原则：谁产生内容，谁负责应用级头；边缘负责协议级头 应用（Node/中间件） ：与内容相关的策略——CSP（是否允许 inline script）、缓存语义（no-store 的接口）、X-Content-Type-Options。 边缘（Nginx） ：与传输相关的策略——HSTS、HTTPS 重定向、点击劫持兜底、指纹屏蔽。 两边都可以设的头（如 X-Frame-Options ），以边缘为准、应用为辅， 不要在两层设置互相冲突的值 。 逐项清单 Strict-Transport-Security（HSTS） ——只在确认 HTTPS 稳定后启用，从小 max-age 开始： add header Strict-Transport-Security \"max-age=300; includeSubDomains\" always; 预检通过后再升到 max-age=31536000 。一旦上大 max-age，测试环境用 HTTP 将被浏览器强制跳 HTTPS 难以回退；若要让浏览器可预加载进内置列表，需要 ≥1 年 + includeSubDomains + preload 三条件齐备，慎之又慎。 Content-Security-Policy ——对个人站最常见的折中（Astro 会大量产出 inline 运行时脚本）： default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; 'unsafe-inline' 是务实起点而非终点：后续可用 nonce/hash 收紧脚本层。关键是先把 frame-ancestors 、 base-uri 、 form-action 这类 零副作用 的指令拉满——它们不关心 inline 与否，收益立现。 X-Frame-Options: DENY ——老浏览器的点击劫持兜底，与 frame-ancestors 并存不冲突。 X-Content-Type-Options: nosniff ——防止浏览器把响应猜成错误类型。零成本，必开。 Referrer-Policy: strict-origin-when-cross-origin ——对外链只泄露 origin，站内保留完整路径。 Permissions-Policy ——按实际功能关闭不需要的能力： camera=(), microphone=(), geolocation=(), payment=() Cache-Control 分层 ——静态产物长缓存要配内容哈希文件名；SSR 页面 public, max-age=0, must-revalidate ；API 响应（尤其健康检查、订阅状态）一律 no-store ，否则 401/429 会被缓存住造成\"幽灵故障\"。 顺序陷阱与验证 1. 重定向链上的头 ： add header 默认只挂在 2xx/3xx 的部分响应上，Nginx 里给跳转响应带头要用 always 参数，否则 HTTP→HTTPS 的 301 上没有 HSTS（浏览器此时还收不到 HSTS——这正确，HSTS 首次生效本来就要求一次成功 HTTPS）。 2. 中间件写头 vs 不可变响应 ：若应用框架给响应对象加头（如 Astro middleware 里 response.headers.set(...) ），注意某些运行时里 Response 头是不可变的，需要克隆后修改，否则线上直接 500——这类错误只在真实流量触发中间件时出现，构建期毫无征兆。 3. 验证三件套 ： curl -sI https://… 逐头核对；Mozilla Observatory 打分回归；上线前用 staging 域名试 HSTS（别拿主域直调 max-age）。 小结 响应头没有高深技巧，只有\"清单 + 分层 + 渐进收紧 + 每次都验证\"。它保护不了所有攻击面，但它消灭的是一整类最低级也最常见的洞——而且账单只有几行配置。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "安全",
        "HTTP",
        "Nginx",
        "自部署"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/sitemap-indexable-boundary/",
      "url": "https://kairos.cn.mt/posts/sitemap-indexable-boundary/",
      "title": "一个该进 sitemap、一个不该进：结果页与详情页的收录边界",
      "summary": "哪些 URL 该进 sitemap、哪些不该——从参数化结果页、SSR 借数据、noindex 策略三条线，讲清个人站的收录卫生。",
      "content_text": "sitemap 不是\"站点所有路由的清单\"，而是\"希望搜索引擎当作文档来抓的清单\"。把两者混为一谈，会往索引里灌进一堆\"每次抓都是不同空页\"的噪音。 该进的：内容页 有独立信息价值、参数无关、稳定可重现的页面：文章详情、项目、标签、系列、工具页、关于。它们的判据是—— 同一个 URL 任何时候抓取，内容基本一致，且对用户有意义 。 不该进的第一类：参数化结果页 一次真实修复： /subscribe/status/ 被收录进 sitemap，但它本质是\"订阅操作的结果回跳页\"——内容完全由查询参数决定，无参数的抓取永远得到\"链接无效\"（见踩坑《\"查看状态页\"点进去永远显示链接无效》）。爬虫每抓一次都是一个不同的错误页，白白消耗抓取预算、还可能把\"链接无效\"当成正文索引。 判据： 去掉参数就没有意义的 URL 不进 sitemap 。若这类页确实需要存在（确认/退订回跳），给它 noindex ，或只在有参数时返回正常、无参数时返回引导态。 不该进的第二类：借数据但无独立价值的 SSR 路由 有一类路由技术上能访问，但对读者零价值：健康检查、内部预览、管理入口。它们要么 noindex ，要么本就不在 sitemap 里。 能访问 ≠ 该收录 。 本项目处理过一例： /weekly-digest （周报预览）本属后台工具，早期挂在公开路由，既无公开入口、又链向一个受保护的接口（访客点开得到裸 401 JSON），还带着大量工程术语——典型的\"误当作公开页\"。最终整体迁入 /admin/ 加会话守卫，公开路径改 404， noindex 也不再需要（整页不再是公开文档）。 noindex 与 sitemap 的分工 不想被收录 ： （告诉爬虫别索引）； 主动告诉爬虫哪些值得来 ：sitemap（ 列表）。 两者要一致：一个页面既 noindex 又出现在 sitemap 里是 自相矛盾 的信号（等于一边说\"别收我\"一边递名片）。健康检查页、结果回跳页若确需 noindex ，就要同时从 sitemap 移除。 一个务实的 sitemap 卫生清单 [ ] 只列内容页与真正公开的工具页 [ ] 排除：带参才有意义的结果页、健康检查、管理预览、内部 JSON/接口 [ ] 每个静态路由都对应一个真实可渲染页面（无参数访问不报错） [ ] noindex 集合与 sitemap 集合无交集 [ ] 详情类条目带 lastmod （内容更新触发重抓） [ ] 死链扫描纳入 release 门禁——sitemap 指向的每条都要 200 抓取预算：个人站也会踩 小站谈不上\"预算耗尽\"，但把无意义参数页喂给爬虫，换来的是一堆\"低质/重复内容\"抓取记录，稀释真正内容页的抓取频率，极端情况下让搜索引擎觉得站点信号混乱。卫生的 sitemap 是一种礼貌：你把\"值得看的页\"划清楚，爬虫就把力气花在那儿。 小结 sitemap 的每个条目都是一句\"这个 URL 值得被当作文档对待\"。参数化结果页、借数据路由、后台预览——它们要么不该收、要么该先\"去参数化才有意义\"的结构问题。判断口诀： 能脱离查询参数独立成立、且对读者有信息价值的页面，才配进 sitemap。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "SEO",
        "sitemap",
        "收录",
        "爬虫"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/ssr-self-fetch-loopback-risk/",
      "url": "https://kairos.cn.mt/posts/ssr-self-fetch-loopback-risk/",
      "title": "SSR 页面为什么不该向自己发 HTTP 请求：回环依赖与文件直读",
      "summary": "分析 SSR 页面在运行时 fetch 自身站点静态文件的隐性风险，给出按候选根目录直读 public 产物、HTTP 兜底并区分空数据与数据不可用的实现模式。",
      "content_text": "问题模式：请求时向自己借数据 不少 SSR 站点有这类代码：页面在服务端渲染时需要一份构建期生成的 JSON（搜索索引、图谱数据、链接体检报告），于是直接在 setup 里写： const response = await fetch(new URL('/graph-data.json', Astro.url).toString()); const data = await response.json(); 它在本地开发几乎总能工作：dev server 正在运行， Astro.url 指向的就是它自己。但在真实部署链路里，这个请求要完整走一遍 nginx → Node 进程 → （可能再回到 nginx） 的路径，等于让服务端进程在响应请求的同时向自己发起新的请求。常见故障场景： 1. HTTPS 强制 + HSTS 回环 ：nginx 对 HTTP 一律 301 到 HTTPS，回环请求跟随后可能撞上自签证书或 localhost 不匹配 SNI 的握手失败。 2. 网络策略禁止 self-connect ：容器网络、防火墙或 systemd 沙箱（ IPAddressDeny 、 ProtectSystem ）可能直接拒绝进程连接自己的监听端口。 3. 无超时的悬挂 ： fetch 没有 signal ，链路任何一环卡住，整个页面渲染就卡住，最终由上游超时给出白屏或 502。 4. 错误伪装成空态 ： catch 后把数据置空，用户看到\"暂无数据\"，运维看日志毫无线索——这是最难排查的一类问题： 失败被渲染成了合法的空 。 修复模式：磁盘直读为主，HTTP 兜底 构建期生成物（ public/graph-data.json 等）在部署后就是磁盘上的静态文件， dist/client/ 里还有一份构建拷贝。服务端渲染要读它们，最直接的路径就是 fs.readFile ： // src/lib/public-data.ts export type PublicJsonResult = { data: T | null; ok: boolean }; export const readPublicJson = async ( fileName: string, selfUrl?: URL | string, ): Promise > => { const roots = [ join(process.cwd(), 'public'), join(process.cwd(), 'dist', 'client'), ]; for (const root of roots) { try { const raw = await readFile(join(root, fileName), 'utf8'); return { data: JSON.parse(raw) as T, ok: true }; } catch { // 换下一个候选根目录 } } if (selfUrl) { try { const response = await fetch(new URL( /${fileName} , selfUrl), { signal: AbortSignal.timeout(2500), cache: 'no-store', }); if (response.ok) return { data: (await response.json()) as T, ok: true }; } catch { // 自请求只是兜底，失败不再升级 } } return { data: null, ok: false }; }; 三个要点： 候选根目录按部署布局排列 。PM2 / standalone 通常以仓库根为 cwd ，此时 public/ 可直接命中；如果未来改为只发布 dist/ 的精简布局， dist/client 候选仍然成立。 HTTP 兜底必须带超时 。用 AbortSignal.timeout 给回环路径一个明确的生命周期，防止兜底本身成为悬挂源。 返回值携带 ok 标志 。让调用方能区分\"读到了，数据本来就是空的\"和\"没读到\"。 页面侧：把两种\"空\"分开渲染 const result = await readPublicJson ('graph-data.json', Astro.url); const unavailable = !result.ok; const graphData = result.data ?? { nodes: [], edges: [] }; {graphData.nodes.length === 0 && ( {unavailable ? '数据暂时不可用，请稍后刷新重试。' : '暂无节点数据。'} )} 对读者来说这是两句话；对排障来说这是一条分界线： \"不可用\"出现在日志和页面提示里，\"空\"才是内容问题 。书签页同理——体检数据读不到时按\"未检查\"降级，并明说\"收藏与打开不受影响\"，避免一个模块的数据故障伪装成整页功能损坏。 什么时候仍然可以用自请求 不是所有回环 fetch 都错。下面这些条件同时满足时风险可控：需要的是 只有 HTTP 层才有的语义 （缓存头、真实状态码探测）、请求目标是 独立服务 而非本进程、且调用方设置了超时与失败分支。只是读一份本地产物就用 HTTP，等于给最简单的路径人为加上网络这一层失败面。 小结 SSR setup 里 fetch(Astro.url 同域路径) 是隐性回环依赖，在强制 HTTPS、网络策略、沙箱化 systemd 环境中可能稳定失败。 构建期生成物请直接从 public/ 、 dist/client/ 候选路径读磁盘；HTTP 兜底必须限时。 永远区分\"数据为空\"与\"数据不可用\"，并在 UI 文案里体现出来——这是对读者诚实，也是对未来的自己友善。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "SSR",
        "Node.js",
        "部署"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/static-search-index-design/",
      "url": "https://kairos.cn.mt/posts/static-search-index-design/",
      "title": "构建期搜索索引：一个 20KB 的 JSON 为什么够用了",
      "summary": "中小内容站不需要搜索引擎——讲清构建期生成扁平 JSON 索引 + 浏览器端过滤的设计：结构、字段取舍、更新链路与失效边界。",
      "content_text": "几百条以内的内容站，搜索不需要 Elasticsearch，不需要 Pagefind，甚至不需要后端接口： 一份构建期生成的 JSON + 浏览器里的字符串匹配 就是完整答案。本文讲这个极简方案的设计与它的失效边界。 索引结构：为\"匹配\"而非\"检索\"设计 { \"generatedAt\": \"2026-09-02T12:00:00Z\", \"items\": [ { \"type\": \"posts\", \"title\": \"构建期搜索索引\", \"href\": \"/posts/static-search-index-design\", \"excerpt\": \"讲清构建期生成扁平 JSON 索引…\", \"tags\": [\"搜索\", \"架构\"], \"date\": \"2026-09-02\" } ] } 字段取舍的原则： 只放\"会被匹配\"和\"会被展示\"的字段 ——标题、摘要、标签是匹配面；正文全文不进索引（体积爆炸且浏览器过滤无收益），需要正文检索再考虑分片或换方案； type 支撑分组展示（文章/项目/踩坑…）； href 是唯一出口，不存内部 ID； generatedAt 用于调试\"索引是不是旧的\"。 一个 20 条目、全中文字段的索引约 10–20KB gzip 后 3KB 以内——比一张缩略图小，这就是\"够用\"的量化依据。 生成链路：挂在 prebuild，构建即更新 { \"prebuild\": \"npm run content:check && npm run search:index && npm run graph:data\", \"build\": \"astro build\" } 关键决策链： 1. 数据源是内容集合本身 （frontmatter + 摘要），不是渲染后的 HTML——生成脚本直接读 markdown，零抓取； 2. 草稿过滤在生成器里做 （ draft !== true ），而不是\"全量生成、前端隐藏\"——搜索索引是公开产物，任何非公开内容都不该出现在字节里； 3. content:check 排在 search:index 之前：坏 frontmatter 先失败，不产出半截索引； 4. 索引写入 public/ ，随构建发布，天然带内容级更新（下次部署即最新）。 浏览器端：两级消费 命令面板（全局）：fetch 一次、缓存到模块变量、 includes + 逐字符模糊打分，输入防抖 120ms 内完成几百条的过滤毫无感知。 搜索页（页面）：更进一步，把索引在 服务端渲染时直接内联进 HTML （ ），省掉首屏一次网络往返——静态索引 + 静态页面，本可以是一个请求。 内联与外置的选择标准：该页专属的数据（本页图谱、本页索引）内联；跨页复用的（命令面板索引）外置 + HTTP 缓存。 失效边界：什么时候这个方案会崩 诚实地列出适用上限： | 信号 | 阈值经验 | 应对 | | ----------------------- | ------------- | --------------------------------------------------- | | 条目数 | > 2,000–5,000 | 索引体积 > 500KB，分片（按类型/首字母）或上专用索引 | | 需要正文全文 | 任何规模 | 构建期分词（Pagefind 类）或服务端检索 | | 需要相关性排序/拼写纠错 | 视体验要求 | 换专用方案，别手写打分 | | 多语言词形变化 | — | 简单 includes 天然不支持，需分词器 | 中小站常年碰不到这些边界，而达到之前的每一分复杂度都是纯负债——这就是\"20KB JSON 够用\"的真正含义： 它不是将就，是这个规模下的正确架构。 一个必做的护栏 索引是\"第二份事实\"，会和渲染产物漂移（改了 frontmatter 没重跑生成）。防线两条：prebuild 强制重跑（构建即最新）；release 门禁里校验\"索引条目的每个 href 都存在于站点可达路由\"——本项目正是这条断言抓出过索引残留。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "搜索",
        "架构",
        "构建",
        "性能"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/url-as-state-search-sync/",
      "url": "https://kairos.cn.mt/posts/url-as-state-search-sync/",
      "title": "把状态放进地址栏：搜索、筛选与可分享 URL 的最小实现",
      "summary": "用 URLSearchParams 与 history.replaceState 给纯前端检索加可分享、可回退、可刷新恢复的状态，避免后退丢词与空白输入框的经典缺陷。",
      "content_text": "纯前端检索的三种经典断点 一个把全部数据内联到页面、在浏览器里过滤的搜索/筛选页很常见。没有后端参与时，它通常有三个共通的断点： 1. 无法分享 ：\"看看这个搜索结果\"只能靠口头描述关键词。 2. 刷新即失忆 ：F5 之后输入框和结果列表回到初始状态。 3. 回退语义断裂 ：从结果点进详情页再按浏览器后退，列表页重新初始化，刚才的查询不见了——地址栏却仍然只写着 /search 。 根因只有一个： 查询状态只活在 DOM 里，没有同步到 URL 。 最小实现：读一次，写每次 页面初始化时从地址栏恢复状态： const initialQuery = new URLSearchParams(window.location.search).get('q')?.trim() || ''; if (initialQuery) { input.value = initialQuery; } render(initialQuery); 每次渲染时把状态写回去。注意这里用 replaceState 而不是 pushState ：用户连续敲字产生的中间态不该逐个进入历史栈，否则一次后退只会退回\"上一个没打完的词\"： const syncUrl = (query) => { const url = new URL(window.location.href); if (query) { url.searchParams.set('q', query); } else { url.searchParams.delete('q'); } const target = ${url.pathname}${url.search} ; if (target !== ${window.location.pathname}${window.location.search} ) { window.history.replaceState(null, '', target); } }; 在防抖后的 render(query) 入口调用 syncUrl(query.trim()) ，三行接入完毕。写入前先比较 pathname + search ，避免把同值 replaceState 变成无意义的噪音操作。 与客户端路由共存的两个坑 坑一：View Transitions / SPA 路由会缓存页面 DOM。 以 Astro 的 为例，站内导航默认缓存当前页 DOM，后退命中缓存时 不会重新执行页面脚本 。如果只写\"初始化时读一次 URL\"，从详情页后退回来时状态其实还在（DOM 没销毁），看起来正常；但若你的页面脚本会在每次 astro:page-load 重新初始化，就必须确认重初始化路径同样从 URL 读状态，而不是拿 DOM 的残留值当真值。两种生命周期模型下， \"URL 是唯一事实来源\" 都成立，只是恢复时机不同。 坑二： replaceState 不影响 popstate 。 它不会制造历史条目，因此回退行为保持干净：从详情页后退时命中列表页缓存，前进/后退跨查询时走正常的页面加载并重新解析 URL。 顺手补上的可访问性收益 状态进 URL 后，还有两个免费的好处： 结果计数容器加上 role=\"status\" aria-live=\"polite\" ，屏幕阅读器会在查询结果数变化时播报\"8 条结果\"； 浏览器自动补全开始认识你的查询词，地址栏输入站点名就能直达带参数的历史 URL。 边界与取舍 只同步值得分享的状态。 视图切换、滚动位置这类个人偏好放 localStorage 更合适；查询词、筛选组合放 URL。全部塞进地址栏会让 URL 难以阅读。 保持参数极简。 一个 q 足矣；把高亮位置、拼音开关等实现细节暴露为查询参数，等于给未来的重构挖坑。 空态也同步。 用户删光关键词时记得 delete('q') ，否则留下一个 /search?q= 的尾巴，看起来像坏了的分享链接。 小结 把\"当前在搜什么\"写进地址栏，本质是选择 URL 而非组件内部状态 作为事实来源。实现只需初始化时读、渲染时 replaceState 写，换来分享、刷新恢复与回退语义三项完整能力——这可能是所有前端页面里性价比最高的一次升级。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "前端工程",
        "History API",
        "搜索",
        "可用性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/view-transitions-layered-motion/",
      "url": "https://kairos.cn.mt/posts/view-transitions-layered-motion/",
      "title": "View Transitions 在多模板博客里的实战分层",
      "summary": "用 Astro ClientRouter 做页面过渡时，如何按\"全局底座 / 页面专属 / 组件内部\"三层管理动画生命周期，避免监听器泄漏和动画叠加。",
      "content_text": "以下内容为可公开验证的通用工程方法，示例均为\"假设 / 演示\"场景，不涉及真实生产数据。 一个容易被忽略的事实 Astro 的 开启后，页面切换不再触发完整的浏览器加载，而是走 View Transitions API（不支持的浏览器自动回退为 MPA 行为）。这带来一个经典问题： 同一个 不会因为\"看起来重新渲染了\"而重新执行。 内联脚本（ is:inline ）：随新页面 DOM 一起被换入，会再次执行。 打包模块脚本（普通 ）：ES Module 只执行一次，第二次进入页面只有事件不监听、DOM 是新的。 所以多模板博客的动效代码必须先回答一个问题： 这段代码应该在哪一层运行、由谁清理？ 三层分层模型 推荐把全站动效代码拆成三层，每层有明确的所有权： | 层 | 归属 | 生命周期 | 典型内容 | | -------- | -------- | ---------------------------- | ---------------------------- | | 全局底座 | 基础布局 | 首次加载执行一次，跨页面存活 | 滚动揭示、涟漪反馈、命令面板 | | 页面专属 | 单个页面 | 进入页面执行，离开时清理 | 星图物理模拟、文章专注模式 | | 组件内部 | 单个组件 | 组件挂载后自管 | 计时器、Observer、浮层 | 全局底座：用\"清理指针\"覆盖旧实例 全局脚本必须能在 DOM 被替换后重建与当前 DOM 的联系。一个可靠的模式是把清理函数挂在 window 上，每次执行先调用旧的： window. dsScrollRevealCleanup?.(); const observer = new IntersectionObserver((entries) => { // ... }); document.addEventListener('astro:before-swap', cleanup); window. dsScrollRevealCleanup = cleanup; 关键点有三个： 1. 脚本开头先执行上一次留下的 cleanup ，保证旧 Observer、旧计时器全部断开。 2. astro:before-swap 是最后的清理窗口，此时旧 DOM 还在，移除文档级监听器最安全。 3. 清理函数里既要 disconnect() Observer，也要 removeEventListener ，缺一都会在长会话中累积。 页面专属： is:inline + 清理指针的组合 页面专属动效（比如力导向图）建议使用 is:inline 脚本：它随页面换入而重新执行，等于每次进入都得到全新实例。配合清理指针，可以覆盖掉上一次的循环： window. graphExplorerCleanup?.(); // ... 初始化画布、物理循环、监听器 ... window. graphExplorerCleanup = () => { window.cancelAnimationFrame(frame); observer.disconnect(); document.removeEventListener('astro:before-swap', cleanup); }; document.addEventListener('astro:before-swap', cleanup); 不这样做的话，一个正在 requestAnimationFrame 循环里跑的物理模拟，会在离开页面后继续烧 CPU——画面没了，计算还在。 组件内部：谁注册，谁注销 组件脚本最常见的泄漏是把匿名函数同时用作注册和注销： // ❌ 这行什么都不做：箭头函数是新的引用 toggle.removeEventListener('click', () => setMode(false)); // ✅ 保存引用再注销 const handler = () => setMode(false); toggle.addEventListener('click', handler); // ... toggle.removeEventListener('click', handler); reduced-motion 不是开关，是分支 很多人把 prefers-reduced-motion 当成一个 CSS 媒体查询一刀切。对涉及 JS 驱动的动画，它是三条不同的代码路径： 1. CSS 部分 ：用 token 化的 duration/easing，在媒体查询里归零。 2. JS 循环部分 ：物理模拟、粒子、打字机这类逐帧逻辑，应该直接不启动 rAF，改为一次性计算终态（比如把力导向模拟同步跑 N 次迭代后画一帧）。 3. 交互语义 ：拖拽、缩放、平移属于直接操作而非装饰动画，reduced-motion 用户仍然需要它们，只是不要\"惯性滑动\"式的持续运动。 第三点最容易被做错：把所有动画都关掉，等于把功能也关掉了。 自检清单 发布前可以按这个顺序过一遍： [ ] 每个 requestAnimationFrame 循环都有停止条件（alpha 衰减、离开视口、页面隐藏）。 [ ] 每个全局脚本都有 window. xxxCleanup 指针，并在开头调用。 [ ] 页面隐藏（ document.hidden ）时动画循环暂停。 [ ] astro:before-swap 里注销了所有文档级监听器。 [ ] Canvas 尺寸跟随容器（ ResizeObserver 而不是只监听 window.resize ）。 [ ] reduced-motion 下逐帧动画不启动，但拖拽/缩放/点击功能完整。 小结 View Transitions 时代的前端动效，核心工程问题不是\"怎么让它动\"，而是\"它由谁拥有、什么时候停止\"。三层分层 + 清理指针 + 三分支的 reduced-motion 处理，可以让全站动画在长会话里保持稳定，也让每个页面的专属效果都有明确的归属。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "动效",
        "View Transitions",
        "JavaScript"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/web-font-loading-tradeoffs/",
      "url": "https://kairos.cn.mt/posts/web-font-loading-tradeoffs/",
      "title": "字体加载的取舍：CLS、FOUT 与\"其实你不需要自定义字体\"",
      "summary": "Web 字体如何造成布局偏移与文本闪烁，font-display/size-adjust/预加载的组合拳，以及系统字体栈在内容站上的合理性论证。",
      "content_text": "字体是品牌感的重要来源，也是首屏性能与布局稳定性的经典重灾区。本文把这条链路的取舍讲透，最后给出一个可能反直觉的结论。 问题怎么发生 HTML 到达 → CSS 解析 → 发现 @font-face → 发起字体请求 → 下载中… → 就绪 → 替换 下载期浏览器按 font-display 描述符的行为分三种： block ：最多约 3s 不可见文本（FOIT），页面有字但看不见——体验最差； swap ：先用回退字体立刻渲染（FOUT），字体到达后 替换 ——可见闪烁； optional ：若网络快于约 100ms 用之，否则本次放弃——最克制。 真正的性能债在 swap 替换的那一刻 ：自定义字体与回退字体的字形宽度/高度不同，替换导致所有文本重排。发生在 LCP 元素上，直接吃 CLS。 组合拳：把 swap 的伤害降到接近无 1. 度量对齐（size-adjust） ——让回退字体假装自己是自定义字体的宽高： @font-face { font-family: 'Headings Fallback'; src: local('Arial'); size-adjust: 104.5%; / 匹配目标字体的平均字宽 / ascent-override: 90%; / 匹配行高度量 / descent-override: 20%; line-gap-override: 0%; } 回退与真实字体度量一致时，swap 前后布局不再偏移——现代在线工具（或 fontaine 库）可自动算出这些值。 2. 提前发现 —— 只用于 首屏关键 的单个字重；给五个字重全 preload 等于用带宽换焦虑。 3. 子集化 ——展示型标题字体只用到几十个字符时，unicode-range 子集能把 300KB 砍到 20KB。 4. 缓存 ——字体文件名带哈希、 Cache-Control: immutable ，回访用户零开销。 回到那个反直觉问题：你真的需要它吗 对个人内容站的冷静成本收益： 正文 ：系统字体栈（ -apple-system, Segoe UI, Roboto, system-ui + 中文回退 PingFang SC, Microsoft YaHei ）就是用户设备上已下载、已针对屏幕调校过的最优解。零请求、零 CLS、跨平台只是略有差异——而正文跨平台一致性对阅读体验并不关键。 代码字体 ： ui-monospace, SFMono-Regular, Menlo, Consolas, monospace 覆盖现代系统，无需下载。 标题/品牌字 ：这是唯一值得权衡的位置——一个子集化的展示字体，若品牌收益真实，配好 size-adjust 后其代价约等于一次 20KB 请求。 一个常见决定（也是许多内容站最终收敛的方案）： 正文与代码全系统栈，标题可选一款子集化展示字体 ——首屏只剩 HTML/CSS 本身，字体链路从关键路径上整个消失。这不是没有审美，是把审美预算花在刀刃上。 决策清单 [ ] 这个字体是\"品牌必需\"还是\"审美惯性\"？ [ ] 正文用系统栈是否完全可接受？（通常是） [ ] 必下的字体有没有做子集化 + size-adjust 回退配对？ [ ] font-display 是否明确设过，而不是跟着默认 auto 赌运气？ [ ] CLS 报告里有没有字体替换贡献的偏移？（DevTools → Performance → Layout Shift 记录可归因） 小结 字体优化的公式：能不下就不下（系统栈）；必须下就小（子集化）+ 快（预加载单个关键款）+ 稳（size-adjust 对齐度量 + swap）。内容站的性能洁癖最终大多收敛到同一个选择：把字体请求从\"性能问题\"降级成\"品牌问题\"——只有后者才值得付那几 KB。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "性能",
        "字体",
        "排版",
        "CLS"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/aria-modal-tab-escape/",
      "url": "https://kairos.cn.mt/pitfalls/aria-modal-tab-escape/",
      "title": "aria-modal 拦不住 Tab：命令面板的焦点逃逸",
      "summary": "给 dialog 加了 aria-modal=true 就以为模态完成了，键盘用户仍能 Tab 进遮罩后的页面；命令面板焦点陷阱缺失的定位与补齐复盘。",
      "content_text": "现象 命令面板做得很完整： Ctrl+K 唤起、上下键选择、Enter 跳转、Esc 关闭、 role=\"dialog\" aria-modal=\"true\" aria-labelledby=… 一个不落。无障碍审计工具也给了高分。但一位键盘测试者反馈：\"面板打开后我按 Tab，焦点跑到面板后面的导航栏上了，然后我迷失在一个看不见的面板外面。\" 根因 一个常见误解： aria-modal=\"true\" 只是给屏幕阅读器的语义声明，它不会阻止真实键盘焦点移出对话框 。原生 才有浏览器级焦点圈闭；用 role=\"dialog\" 手写的弹层，Tab 顺序仍按 DOM 全局遍历，面板后面的整个页面都在 Tab 序列里。 命令面板 DOM 通常只覆盖页面前部（挂在 body 末尾的浮层），其后紧跟的隐藏内容一旦可聚焦，Tab 就逃逸。同时面板若没把背景内容设 inert / aria-hidden ，读屏器用户也会读到\"面板 + 整个背景页\"。 修复：显式焦点圈闭 监听 keydown ，在面板打开时手动把 Tab 兜回内部： if (event.key === 'Tab') { const focusable = [ ...root.querySelectorAll( 'button:not([disabled]), a[href], input, [tabindex]:not([tabindex=\"-1\"])', ), ].filter((el) => el.offsetParent !== null); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey && (active === first || !root.contains(active))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (active === last || !root.contains(active))) { event.preventDefault(); first.focus(); } } 要点： 每次动态查询可聚焦元素（结果列表会随输入重绘），不要缓存； 处理\"焦点已不在 root 内\"的兜底（Tab 到边缘时 document.activeElement 可能已逃逸到 body）； 换页/关闭时移除该 keydown 监听，避免 SPA 路由下重复叠加。 更彻底的方案 原生 + showModal() 自带焦点圈闭、背景 inert、Esc 关闭，是现代浏览器下更省心的选择。用 role=\"dialog\" 手写时，除了焦点陷阱还应把触发器所在的背景容器加 inert （或 aria-hidden=\"true\" + 禁用内部 tabindex），三件事齐全才算\"模态完成\"。 验证 自动化断言：打开面板 → 连按 12 次 Tab → 断言 document.activeElement.closest('#command-palette') 非空。任何一次逃逸都会被抓到。这个检查已固化进本项目的交互回归套件。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "可访问性",
        "键盘导航",
        "ARIA",
        "模态"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/define-vars-top-level-const-redeclare/",
      "url": "https://kairos.cn.mt/pitfalls/define-vars-top-level-const-redeclare/",
      "title": "define:vars 的顶层 const 让客户端换页后工具页集体死亡",
      "summary": "Astro 内联脚本配合 define:vars 生成顶层声明，在 ClientRouter 下重复执行触发 SyntaxError，八个工具页交互一夜全灭的复盘。",
      "content_text": "现象 站点启用 View Transitions 客户端路由后，实验室工具页首次访问一切正常；但从首页点进任一工具，或工具 A 切到工具 B 之后，页面渲染完整、样式正常，唯独交互全部失灵——按钮无响应、输入无反馈，控制台里躺着一条刺眼的 SyntaxError: Identifier 'config' has already been declared 。 更迷惑的是复现条件：手动刷新（F5）立刻恢复；站内导航过去才复发。 排查 问题脚本都长这样： const config = toolConfig; // 后续几百行都依赖 config define:vars 的展开方式是把变量以 顶层 const 声明 插进脚本头部。View Transitions 的客户端换页不重建 document ，脚本体会在新页面里 重新执行 ——第二次执行时，上一个页面留下的同名顶层绑定仍在同一全局作用域，重复 const 直接抛 SyntaxError，整个脚本块从第一行起死亡，后面的逻辑一行都没跑。 这解释了全部症状：首次直访 = 首次声明，正常；站内导航 = 重复声明，抛错后整段脚本被跳过。 根因与修复 根因是 把脚本当模块用，却忽略了内联脚本共享同一个全局作用域 。修复分两步： 1. 数据与代码分离：配置改为 JSON script 标签注入，脚本体内不再有任何顶层 const 依赖外部插值： <script type=\"application/json\" id=\"lab-config\" set:html={JSON.stringify(toolConfig)} (() => { const config = JSON.parse( document.querySelector('#lab-config')?.textContent || '{}', ); // … })(); 2. IIFE 包裹一切：顶层作用域不再产生绑定，重复执行天然安全。 教训 客户端路由会把\"页面脚本只会执行一次\"的假设全部作废。 引入 时，应把所有内联脚本当作\"可能执行 N 次\"来审计。 define:vars 适合读一次就用的场景（内联样式变量、单一字符串），一旦脚本体在 SPA 导航中重复执行，它生成的顶层声明就是地雷。 验证手段：浏览器里点击站内导航切换同类页面，观察控制台是否出现 already been declared ；CI 中可用 Playwright 做跨导航冒烟测试——本项目后来把\"lab 跨工具导航无控制台错误\"固化为常规回归项。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "View Transitions",
        "调试"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/feed-channel-contract-drift/",
      "url": "https://kairos.cn.mt/pitfalls/feed-channel-contract-drift/",
      "title": "订阅页承诺了\"踩坑频道\"，RSS 里却只有文章",
      "summary": "订阅表单提供多主题频道，但 RSS/JSON Feed 生成器只遍历文章集合，导致按频道订阅的读者永远收不到非文章内容的一次契约漂移复盘。",
      "content_text": "现象 订阅页把频道列为\"文章更新 / 项目动态 / 踩坑记录 / 全部内容\"，页脚也引导读者\"不想用邮箱可以走 RSS\"。然而检查 RSS 输出时发现： rss.xml 和 feed.json 的 items 只来自 getPublishedPosts() ，完全不含已发布的踩坑——尽管踩坑数量当时已远不止零。一个明确承诺了\"多渠道内容\"的订阅入口，背后却只有一条内容管道。 根因 这是典型的\"多数据源契约漂移\"： rss.xml.ts 与 feed.json.ts 各自独立遍历 posts 集合（历史原因，文章最早存在）； 订阅频道列表定义在另一处（前端硬编码 channels ）； 没有任何机制保证\"频道承诺 ⊆ Feed 实际覆盖\"。 两边各自演进，契约悄悄错位——单测不会报，页面不会坏，只有用户发现\"订阅了踩坑却一条都收不到\"。 修复 统一两个 Feed 生成器的数据源为\"全部公开内容\"：合并 posts + pitfalls，按 updatedAt ?? publishedAt 排序。RSS 条目 指向 /pitfalls/ ， 用标签。修复后条目从 17 增至 22。 教训 \"频道\"是一个对用户的承诺 ，要么 Feed 包含它，要么订阅页删掉它——两者不一致比只做一个更糟。 多数据源 Feed 应该由一个共享选择器导出（例如 getPublicFeedEntries() ），避免每个协议文件手写一次。 契约检查最好变成自动化：订阅页的 channels 与 Feed 实际覆盖的类型集合应在构建期做一次一致性扫描（本项目把 Feed 纳入 release-gate 的产物检查项，后续可加这一步断言）。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "RSS",
        "Feed",
        "数据契约",
        "订阅"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/grid-select-min-content-blowout/",
      "url": "https://kairos.cn.mt/pitfalls/grid-select-min-content-blowout/",
      "title": "一个 select 撑破整页：长 option 与网格轨道的 min-content 陷阱",
      "summary": "原生 select 的固有宽度等于最长 option 文本，作为网格项时会撑大整条隐式轨道，360px 下页面横向溢出 154px 的复盘。",
      "content_text": "现象 探索地图页在桌面端一切正常；数据增长到 107 个节点后，360px 视口出现约 150px 的横向滚动。页面其余部分（星图画布、筛选 chips、节点列表）都做了换行与收缩处理，溢出元素探针却指向一个看似无辜的下拉框区域。 排查 用\"从 document.documentElement.scrollWidth 反查最右子叶元素\"的定位法，命中链落在路径起点/终点选择器上： 一个很长的中文标题… 三个 CSS 事实叠加成了事故： 1. 原生 select 的固有宽度由最长 option 决定 ，且没有可靠的截断样式（ text-overflow 对下拉框本体不生效）； 2. 网格的隐式轨道是 minmax(auto, 1fr) —— auto 的最小值就是子项的 min-content 宽度； 3. 子项（aside）未声明 min-width: 0 ，于是 select 的 498px min-content 一路向上传播，把本应 328px 的轨道撑到 498px。 lg 断点前它是单列，撑破的恰恰是最不该溢出的移动端。 修复 三处最小改动，缺一不可： / 轨道：显式允许收缩 / .series-timeline { grid-template-columns: minmax(0, 1fr); } … 下拉弹层里 option 文本过长仍由浏览器原生处理（弹层可滚动/超出），但页面级布局不再被绑架。 泛化清单 同类\"固有宽度炸弹\"还有几个惯犯： 长默认值、 nowrap 的胶囊标签、含长 URL 的 、 缺 max-width 。防御是成对出现的： 网格/flex 的每一层链路上，容器轨道用 minmax(0,1fr) ，子项配 min-width: 0 ，叶子控件自带 w-full max-w-full 。只要链条断一层，min-content 就会穿过去。 验证方式 比\"肉眼拖窗口\"更可靠的是程序化审计：遍历 sitemap，在 360px 下测 scrollWidth - innerWidth ，对超阈值页面反查\"右侧越界且祖先无 overflow 裁剪\"的最右叶元素——本项目把这一检查固化成了全量路由扫描，一轮就抓出了这个 select 和另外两处同源问题。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "CSS",
        "响应式",
        "布局",
        "表单"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/location-href-bypasses-client-router/",
      "url": "https://kairos.cn.mt/pitfalls/location-href-bypasses-client-router/",
      "title": "window.location.href 悄悄旁路了你的客户端路由",
      "summary": "在启用 View Transitions/SPA 路由的站点里，命令面板与终端用 window.location.href 跳转会强制整页刷新，丢失过渡与内存态的复盘与锚点点击修复法。",
      "content_text": "现象 命令面板（Ctrl/Cmd+K）、彩蛋终端的 open 、首页可交互终端——这些\"输入即跳转\"的交互，视觉上工作正常，但体验上有一处不对劲： 点击跳转时页面整页重载 。表现是 View Transitions 的淡入淡出动画没有出现，某些存在内存里的临时状态（比如本会话已展开的面板）被清空。 根因 这些跳转统一用了： window.location.href = target; 在 Astro （或任何 History-API 驱动的 SPA 路由）里，路由拦截器监听的是 click 事件与 popstate ，而不是 location.href 的赋值。直接改 location.href 属于\"编程式整页导航\"，浏览器会老老实实卸载当前文档、重新请求目标——路由的增量渲染层根本没机会介入。于是 SPA 退化成 MPA。 修复：合成锚点点击 让跳转\"看起来像用户点了链接\"，路由拦截器自然接管： const navigateTo = (href) => { if (!href) return; const link = document.createElement('a'); link.href = href; document.body.append(link); link.click(); link.remove(); }; 点击一个同域 会触发路由的 astro:link-click /拦截逻辑，走增量换页；而 如果路由不可用 （首屏未水合、浏览器不支持），这次点击自动降级为普通导航，不会比 location.href 更差。零额外依赖。 对需要绝对 URL 的场景（如接口返回的 statusUrl ）同样适用，浏览器会解析 link.href 。 何时仍该用 location.href 不是所有跳转都要 SPA 化。这些情况 故意 整页刷新更合理： 跳转到站外/不同子域（本就该脱离路由）； 强制重载以拉取新构建产物（发布预览）； 登录/登出后需要清空所有内存态。 区分标准是\"目标是否在同一应用壳内\"。站内常规导航交给路由，跨边界或需要重置的场景保留 location.href 。 验证 一个便宜的回归断言：跳转前在页面 window. spaMarker = 1 ，跳转后读回——若仍是 1 说明文档没被卸载，走了 SPA 路径；若变 undefined 说明整页刷新了。本项目命令面板与终端跳转的回归测试就用了这个标记法。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "View Transitions",
        "导航",
        "调试"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/parameterless-status-page-invalid-trap/",
      "url": "https://kairos.cn.mt/pitfalls/parameterless-status-page-invalid-trap/",
      "title": "「查看状态页」点进去永远显示链接无效：无参数路由的状态页陷阱",
      "summary": "状态结果页只按查询参数渲染，直接访问或从无参数入口进入时必然落入 invalid 分支，还被 sitemap 收录放大问题的一次复盘。",
      "content_text": "现象 订阅页上有两个\"查看状态页\"入口，链接指向 /subscribe/status 。但状态页的实现是： const state = isSubscriptionState(urlState) ? urlState : 'invalid'; 不带 ?state= 的直接访问全部落入 invalid 分支，标题赫然是 \"链接无效 / 无法处理\" 。也就是说，站点主动引导用户去点一个必然报错的页面。更糟的是这个无参数路径被收录进了 sitemap——搜索引擎抓回来的每一页都是错误提示。 根因 把\"操作结果页\"当成了\"状态查询页\"来设计。这个页面真实的语义只有一个： 渲染某次订阅操作的结果 ，结果信息全部来自重定向携带的查询参数。它没有\"查询当前订阅状态\"的能力（那需要后端凭 token 或邮箱，属于另一个功能的范畴），但入口文案\"查看状态页\"承诺的恰恰是后者。语义与承诺错位，实现忠实于语义，于是入口一碰就碎。 修复 新增 idle 引导态 ： state 参数缺失时不再判死，渲染使用说明——告诉访客这个页面会在校验邮箱、确认、退订后自动到达，并提供\"前往订阅 / RSS / 返回首页\"三个出口。 invalid 回归本义：参数存在但无法识别。 sitemap 移除该路由 ：带参数才有意义、且每次访问内容都不同的结果页，不是可索引文档。 顺带清理了同类问题：一个挂在公开路由的管理预览页（含受保护 API 链接，访客点击得到裸 401 JSON）整体迁入后台并加会话守卫。 教训 每一个能导航到达的 URL，都要回答\"用户不带任何上下文直接访问时，看到什么\"。 答案不应该是错误页——除非它真的是 404。 结果页 / 回跳页默认不进 sitemap；能进 sitemap 的页面必须是无参数可稳定渲染的。 状态映射建议至少留三态：缺参（idle 引导）、坏参（invalid 纠错）、好参（正常渲染）。二元判断是这类事故的温床。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "信息架构",
        "SEO",
        "可用性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/scroll-reveal-permanent-hide/",
      "url": "https://kairos.cn.mt/pitfalls/scroll-reveal-permanent-hide/",
      "title": "滚动进场动画把内容永久藏起来了：IntersectionObserver 的三个降级洞",
      "summary": "用 IntersectionObserver 做渐入揭示时，打印、脚本禁用、观察者漏触发三种情况会让内容永久不可见，配自愈与打印兜底的解法。",
      "content_text": "现象 一个用 [data-scroll-reveal] 做\"进入视口渐入\"的列表页：正常浏览一切优雅。但三个场景暴露了问题—— 1. 用户 Ctrl+P 打印 或某些 PDF 快照工具抓取时，未滚动到的下方区块 整块空白 ； 2. JS 被扩展或网络问题中断时，元素停在 CSS 初始的 opacity:0 ，读者看到一片\"有字但看不见\"的区域； 3. 个别元素因布局时机（图片撑高后已全在视口外上方）永远不触发 IO 回调，永久隐藏。 根因是同一个： 进场动画默认把\"未揭示\"设成了视觉不可见，却没有为\"揭示逻辑没跑成\"留后路 。 修复三件套 一、打印兜底 ——打印媒体里强制全部可见： @media print { [data-scroll-reveal] { opacity: 1 !important; transform: none !important; } } 二、自愈定时器 ——任何环境下，超时未揭示就直接标记为已揭示，绝不永久隐藏： window.setTimeout(() => { document .querySelectorAll('[data-scroll-reveal]:not([data-revealed])') .forEach((el) => markRevealed(el)); }, 6000); 三、reduced-motion / 无 IO 直接静态可见 ——初始化时判定，不注册观察者： const prefersReduced = window.matchMedia( '(prefers-reduced-motion: reduce)', ).matches; if (prefersReduced || !('IntersectionObserver' in window)) { document.querySelectorAll('[data-scroll-reveal]').forEach(markRevealed); return; } 原则：动效是增强，不是内容显示的前提 一条通用铁律—— 内容的最终可见性不能依赖任何 JS 回调成功执行 。CSS 的\"初始隐藏态\"必须同时满足： 打印/PDF：覆盖为可见； JS 关闭/报错：要么初始就可见（渐进增强），要么有 或超时兜底； 减弱动效偏好：直接可见； 观察器异常：自愈超时。 把进场动画想成\"锦上添花的延迟出现\"，而不是\"内容的开关\"。开关永远握在无 JS 也能成立的路径里。 验证 浏览器开发面板切换 prefers-reduced-motion ，确认全部区块立即可见； 模拟 JS 抛错（控制台临时 throw），确认内容仍显示； 打印预览确认无空白块。这三条都能用 Playwright 固化（本项目在 QA 轮做了 6 页 reduced-motion 免滚动检查，即基于此）。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "动效",
        "IntersectionObserver",
        "降级",
        "可访问性"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/ssr-self-fetch-empty-state-mask/",
      "url": "https://kairos.cn.mt/pitfalls/ssr-self-fetch-empty-state-mask/",
      "title": "SSR 页面 fetch 自己的静态文件，图谱在部署环境安静地空了",
      "summary": "服务端渲染时向自身 origin 发起回环请求读构建产物，被 HTTPS 重写拦截后错误落入 catch，页面把\"取数失败\"伪装成\"暂无数据\"的复盘。",
      "content_text": "现象 图谱页在本地开发显示几十个节点，部署到 nginx + PM2 的服务器后变成\"暂无节点数据\"。构建全绿， public/graph-data.json 确实存在且内容正常，浏览器 Network 里却完全看不到这个文件的请求——因为读取发生在服务端，失败点藏在 SSR 里。 排查 页面 setup 里最初的写法： const response = await fetch(new URL('/graph-data.json', Astro.url).toString()); 这是让 Node 进程 向自己的站点发起 HTTP 请求 。链路上任何一环都可能让它失败：nginx 对 HTTP 一律 301 到 HTTPS 后回环证书校验不过、宿主网络策略禁止进程 self-connect、上游代理超时。而外层 catch 只有一行\"把数据置空\"，于是所有失败都呈现为同一种体面但致命的状态—— 空数据 。日志里没有错误，页面上没有异常，只有内容悄悄消失。 定位手法：在 SSR 进程里手动打同一条 URL 的日志与 NODE DEBUG=http 输出对比，再在 catch 里临时记录 error 名称，很快看到 unable to verify the first certificate ——HTTPS 强制回环撞上证书链校验。 修复 改为文件系统直读，HTTP 路径仅作带超时的兜底，并且把两种\"空\"分开渲染： // 候选根目录：开发态命中 public/，精简部署命中 dist/client/ const result = await readPublicJson ('graph-data.json', Astro.url); const unavailable = !result.ok; UI 层： unavailable 显示\"数据暂时不可用，请稍后刷新\"，真空数据显示\"暂无节点数据\"。书签页的链接体检报告同步改造。 教训 永远不要让 SSR 进程通过自己的对外 URL 取本地文件 。磁盘直读没有网络这一层失败面。 catch 后置空是最危险的降级 ：它把故障渲染成合法状态，让监控、日志、用户三双眼睛同时失明。降级可以，但必须带上\"这是降级\"的可见信号。 凡是\"本地正常、线上空了\"的数据问题，优先怀疑取数路径在部署链路里多了什么环节（协议重写、证书、代理、防火墙）。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "Astro",
        "SSR",
        "部署",
        "Nginx"
      ]
    },
    {
      "id": "https://kairos.cn.mt/pitfalls/subscribe-503-misclassified-as-invalid/",
      "url": "https://kairos.cn.mt/pitfalls/subscribe-503-misclassified-as-invalid/",
      "title": "订阅接口 503 被前端翻译成了\"邮箱格式不对\"",
      "summary": "后端在数据库故障时复用了 invalid 状态码，前端按 state 映射文案，服务不可用被误导为输入错误的跨层契约复盘。",
      "content_text": "现象 一次例行代码走查中发现：订阅接口的 catch 分支在数据库写入失败时返回 state: 'invalid' + HTTP 503 + message: '订阅服务暂时不可用，请稍后重试。' 。而前端只按 state 映射两种文案： rate-limited → \"尝试次数过多\"，其余一律 → \"邮箱格式似乎不对，请检查后重新提交。\" ——服务端精心准备的 message 被整块丢弃。 后果：服务真挂了的时候，用户反复修改正确的邮箱地址，越改越困惑；\"稍后再试\"的引导完全没机会触达。 根因 两层各自的问题叠在一起： 1. 服务端状态复用 ： invalid 本意是\"输入无效\"，故障分支图省事也塞进了 invalid ，真实原因只活在没人读的 message 字段里。状态枚举 ['pending','confirmed','invalid',...] 里没有一个表达\"服务暂不可用\"的取值。 2. 前端把映射写死成二元 ： state === 'rate-limited' ? A : B ，隐含假设\"失败必是输入问题\"。 修复 前端： payload.message 存在时优先透传，否则按 response.status 兜底分桶（429 限流文案、≥500 服务不可用文案、其余输入文案）。 状态页：当服务端通过 redirect 带上 message 查询参数时同样优先展示，保证无 JS 的原生表单流也看到真实原因。 服务端（遗留建议）：为服务故障引入独立的 unavailable 状态值属于契约变更，按变更控制流程登记后再做。 教训 面向用户的文案要透传，不要在客户端重新发明。 服务端最清楚失败原因，前端的职责是渲染而非猜测。 错误状态枚举要能区分\"用户可修复\"与\"用户不可修复\"两类，前者引导改输入，后者引导等待或换渠道（RSS）。 走查这种问题最有效的手段：把接口的每一个失败分支在本地用 mock 打出来，对照前端实际显示——本项目随后用 Playwright 路由拦截把\"503 透传\"固化成了回归用例。",
      "date_published": "2026-09-02T00:00:00.000Z",
      "date_modified": "2026-09-02T00:00:00.000Z",
      "tags": [
        "API 设计",
        "错误处理",
        "订阅"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/astro-content-system/",
      "url": "https://kairos.cn.mt/posts/astro-content-system/",
      "title": "Astro 内容系统如何承载长期写作",
      "summary": "用 Content Collections 把文章元数据、草稿状态和构建期路由统一管理。",
      "content_text": "为什么先做内容系统 个人博客最核心的资产不是页面壳，而是可长期积累的内容。Astro 的 Content Collections 适合把 Markdown 或 MDX 内容变成类型安全的数据源。 当前项目先把文章集合、列表页、详情页和归档页串起来，形成最小可用闭环。 元数据边界 文章 Frontmatter 需要稳定，后续 RSS、搜索、站点地图和知识图谱都会依赖这些字段。 title 用于页面标题和列表展示。 description 用于 SEO 摘要和卡片摘要。 publishedAt 控制排序和归档。 tags 支撑标签筛选和相关文章推荐。 draft 决定内容是否公开分发。 下一步 内容系统跑通后，可以继续补标签页、系列页、RSS、sitemap 和 Pagefind 搜索索引。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "Astro",
        "内容系统",
        "TypeScript"
      ]
    },
    {
      "id": "https://kairos.cn.mt/projects/astro-content-workbench/",
      "url": "https://kairos.cn.mt/projects/astro-content-workbench/",
      "title": "Astro 内容工作台",
      "summary": "围绕文章、笔记、项目、踩坑记录和搜索索引构建的内容生产辅助模块。",
      "content_text": "Astro 内容工作台负责把博客的内容入口统一起来：文章提供深度沉淀，笔记记录短内容，项目承载工程履历，踩坑记录则用于复盘问题根因。 核心能力 使用内容集合 schema 校验 Frontmatter，减少发布时的元数据遗漏。 通过构建脚本生成搜索索引，让公开内容可以被站内搜索消费。 通过 sitemap、RSS 和 JSON Feed 提供搜索引擎与订阅分发入口。 通过项目关联文章字段，把项目复盘和技术文章连接起来。 适用场景 这个模块适合持续写作型博客：内容数量增加后，站点仍然能靠标签、系列、归档、搜索和项目关联关系保持可浏览。 后续计划 后续会把内容质量检查结果、项目复盘和知识图谱静态数据进一步打通，让内容从“能发布”升级为“可维护、可回溯、可探索”。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "Astro",
        "内容系统",
        "SEO"
      ]
    },
    {
      "id": "https://kairos.cn.mt/projects/myblog-platform/",
      "url": "https://kairos.cn.mt/projects/myblog-platform/",
      "title": "程序员奇趣博客",
      "summary": "基于 Astro 的个人博客、知识工作台和开发者实验室。",
      "content_text": "这个项目会先完成内容系统、搜索、SEO 和订阅能力，再逐步加入评论、统计、后台、实验室工具和知识图谱。 当前阶段重点是让文章写作和阅读链路先稳定下来。 建设目标 让文章、笔记、项目和踩坑记录都由 Astro Content Collections 提供类型校验。 让公开页面优先静态生成，RSS、JSON Feed、sitemap 和搜索索引在构建期完成。 让实验室工具在浏览器本地运行，避免把用户输入上传到服务端。 让宝塔、PM2、Nginx 和 Node Adapter 的部署路径保持清晰。 当前状态 内容系统、标签、系列、归档、搜索、Feed、项目墙和实验室 P0-P1 工具已经形成基础闭环。下一阶段会继续接入动态互动、后台管理和知识图谱能力。 复盘重点 这个项目的关键不是把功能一次性做满，而是把每个模块做成能被构建、搜索、订阅和后续后台复用的稳定数据源。项目页会继续承载每个阶段的工程决策和踩坑记录。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "Astro",
        "TypeScript",
        "自部署"
      ]
    },
    {
      "id": "https://kairos.cn.mt/changelog/content-platform-mvp/",
      "url": "https://kairos.cn.mt/changelog/content-platform-mvp/",
      "title": "内容平台 MVP 闭环",
      "summary": "完成文章、标签、系列、归档、搜索、Feed、项目墙和实验室基础能力，站点进入可持续迭代阶段。",
      "content_text": "发布摘要 站点已经从基础博客骨架推进到内容平台 MVP。公开内容可以通过文章列表、标签、系列、归档、搜索、Feed 和 sitemap 被访问和分发。 本次价值 写作内容有了类型校验和统一元数据。 读者可以通过多种入口发现文章、项目和工具。 后续动态互动、后台管理和知识图谱可以复用现有内容结构继续扩展。 下一步 继续推进不依赖服务端状态的静态能力，例如更新日志、书签导航、踩坑地图和知识图谱静态数据。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "Astro",
        "内容系统",
        "站点建设"
      ]
    },
    {
      "id": "https://kairos.cn.mt/changelog/static-changelog-launch/",
      "url": "https://kairos.cn.mt/changelog/static-changelog-launch/",
      "title": "静态更新日志上线",
      "summary": "新增 changelog 内容集合和更新日志页面，用于记录站点功能发布、文章重要更新和阶段成果。",
      "content_text": "发布摘要 更新日志作为独立内容类型上线，后续每次功能发布、内容结构调整和重要文章更新都可以记录在这里。 本次价值 项目迭代过程有了公开、可追溯的时间线。 更新记录默认进入站点地图和搜索索引。 开发路线图中的阶段成果可以和真实页面互相校验。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "Changelog",
        "内容集合",
        "产品迭代"
      ]
    },
    {
      "id": "https://kairos.cn.mt/notes/#first-note",
      "url": "https://kairos.cn.mt/notes/#first-note",
      "title": "第一条开发笔记",
      "summary": "用短笔记记录博客开发过程中的小决策和待办线索。",
      "content_text": "短笔记适合保存还没有扩展成长文的想法，例如一次构建问题、一个组件命名、一次部署参数调整。 后续可以把高频出现的短笔记整理成正式文章或踩坑记录。",
      "date_published": "2026-07-20T00:00:00.000Z",
      "date_modified": "2026-07-20T00:00:00.000Z",
      "tags": [
        "笔记",
        "开发记录"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/developer-lab-roadmap/",
      "url": "https://kairos.cn.mt/posts/developer-lab-roadmap/",
      "title": "开发者实验室的工具优先级",
      "summary": "从 JSON、时间戳、URL 和 Base64 开始，先覆盖高频本地工具场景。",
      "content_text": "工具页的判断标准 实验室不是功能堆叠，而是给日常开发提供低摩擦入口。第一批工具优先选择无需服务端参与、不会上传隐私数据、可以纯浏览器运行的能力。 P0 工具 JSON 格式化和压缩。 时间戳秒和毫秒识别。 URL 编解码。 Base64 文本编解码。 体验原则 工具页面应该启动快、反馈明确、错误信息可读。后续实现时会优先保留输入内容，避免一次错误导致上下文丢失。",
      "date_published": "2026-07-19T00:00:00.000Z",
      "date_modified": "2026-07-19T00:00:00.000Z",
      "tags": [
        "实验室",
        "工具",
        "产品设计"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/self-hosted-deploy-notes/",
      "url": "https://kairos.cn.mt/posts/self-hosted-deploy-notes/",
      "title": "面向宝塔部署的 Astro SSR 思路",
      "summary": "Astro Node Adapter、PM2 和 Nginx 反向代理可以组成清晰的自部署路径。",
      "content_text": "部署目标 这个博客不是纯静态演示站，而是计划支持评论、统计、后台和订阅能力。因此项目采用 Astro SSR 输出，并使用 Node Adapter 生成服务端入口。 运行链路 生产环境可以由 Nginx 接收公网流量，再反向代理到本地 Astro Node 服务。PM2 负责进程守护、开机自启和日志管理。 配置重点 SITE URL 决定 canonical、RSS 和 sitemap 的绝对地址。 MySQL 和 Redis 只通过环境变量传入。 构建后由 node ./dist/server/entry.mjs 启动服务。 这条路线和宝塔常见 Node 项目管理方式兼容，也方便后续加入健康检查和回滚流程。",
      "date_published": "2026-07-18T00:00:00.000Z",
      "date_modified": "2026-07-18T00:00:00.000Z",
      "tags": [
        "Astro",
        "部署",
        "宝塔"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/content-health-checklist/",
      "url": "https://kairos.cn.mt/posts/content-health-checklist/",
      "title": "内容健康检查应该覆盖什么",
      "summary": "SEO、草稿泄漏、重复 slug 和图片 alt 是博客公开发布前的基础门禁。",
      "content_text": "检查价值 内容站长期维护时，问题往往不是代码突然坏掉，而是文章元数据缺失、链接失效、草稿误发布或图片缺少替代文本。 第一批规则 公开文章必须有标题、摘要、发布时间、标签和分类。 草稿不得进入列表、搜索、RSS 和 sitemap。 slug 使用小写短横线，避免中文路径带来的分享兼容问题。 图片需要明确 alt 文本。 集成方式 内容健康检查适合先做成脚本，再接入 CI 和后台只读面板。error 阻断发布，warning 输出报告。",
      "date_published": "2026-07-17T00:00:00.000Z",
      "date_modified": "2026-07-17T00:00:00.000Z",
      "tags": [
        "SEO",
        "内容质量",
        "CI"
      ]
    },
    {
      "id": "https://kairos.cn.mt/posts/writing-workflow-for-programmers/",
      "url": "https://kairos.cn.mt/posts/writing-workflow-for-programmers/",
      "title": "程序员博客的写作工作流",
      "summary": "把技术文章、踩坑记录和项目复盘拆成不同节奏，降低长期更新成本。",
      "content_text": "内容类型要分层 程序员博客很容易因为文章标准过高而停更。更稳的方式是把内容分成不同颗粒度：长文、短笔记、踩坑记录和项目复盘。 推荐节奏 技术文章适合沉淀完整思路。 短笔记适合记录当天解决的问题。 踩坑记录保留现象、根因和修复方案。 项目复盘展示工程判断和取舍。 和站点能力结合 当内容类型稳定后，标签、系列、归档、搜索和知识图谱才能真正发挥作用。站点结构应该服务写作习惯，而不是反过来增加维护负担。",
      "date_published": "2026-07-16T00:00:00.000Z",
      "date_modified": "2026-07-16T00:00:00.000Z",
      "tags": [
        "写作",
        "知识管理",
        "博客"
      ]
    }
  ]
}