来源:notes/documents/网页版阅读面与日程分栏实施记录.md

网页版阅读面与日程分栏:实施记录

Status: done(2026-09-23 落地)

上游依据:notes/documents/网页版阅读面自适应与日程分栏阅读.md(note_6f93685ef27c44c4,原型与定案)、 web/docs/adr/0013-page-canvas-and-split-reading.md(第一次让「页」有了页内横向构图)。 代码提交 b0f2150(feat: 阅读面加画布居中与目录轨道,日程改为同页分栏读笔记)、 a8ef290(fix: 叶内正文补回右留白,窄屏的右留白改回 16)。

落地的两件事

场景 页内构图 门槛(页这一列的宽度)
读一页文档 「页边 72 + 正文 640」整块在页里居中(.page-canvas 一律
一页长文档 居中 + 右侧 240px 目录轨道,缝 48(sticky、滚动高亮、点击平滑跳转) ≥1080,且这篇有 ≥2 个 h2/h3
日程里点一条笔记 同一页分两栏:左栏收缩、右叶滑出 ≥1196 才并排,否则叶整宽滑入

度量一个都没动:页边仍 72、正文仍 640、行宽仍 34 字,动的是它在页里的位置。叶里另算—— 叶 712 = 页边 72 + 正文 600 + 右留白 40,640 是页这一列的度量,叶比它窄时先让给留白(见下)。 实测(本地预览,CSS 像素):

视口 页这一列 画布 正文列 目录轨道
1920 1556 1080,居中 714 – 1354 240,在正文右侧 48
1444(刚过门槛) 1080 1080 整 640 240
1443(门槛下) 1079 792,居中 整 640 不出
1280 916 792,居中 538 – 1178 不出
430(窄屏) 430 不再加留白 16 – 398(两边各 16) 不出

日程(1920):左栏 1556 → 844,叶 713 停在 x=1207;1560:左栏 646、叶 551;1559:叶整宽 1196 滑入。

落地后的阅读面:画布居中,右侧一条 240 的目录轨道(1920 · 浅色)

同一屏 · 夜纸

门槛两侧:视口 1443 只居中,1444 出轨道且画布刚好塞满页列

日程里点开一条笔记:日历留在原地,右叶 712 滑出(1920 · 浅色)

同一屏 · 夜纸

关闭也有动画:这一帧抓在真时长的中途,叶的 translateX 还剩 624/713

实现期改掉的三处(原型里没有的东西)

一、门槛从「视口 ≥1400」改成「页这一列 ≥1080」,用 @container 而不是媒体查询。 照原型里视口的 1400 编出来一量:视口 1400–1443 这段里,页这一列(1036–1079)装不下整张画布(1080),网格把正文列 挤到 596px——度量动了,而这套设计最不该动的就是它。视口宽度还和脊(64)、索引(300)的宽度耦合, 那两个数字将来一改,媒体查询就悄悄错。改成 .page { container-type: inline-size } + @container page, 门槛问的就是页这一列自己:1080 = 72 + 640 + 48 + 240 再加画布两侧 80 的留白,1196 = 叶 712 + 日历侧 484。

二、叶的 ref 回调每次提交都会重排一次 setShown(true) 原写法是 ref={(node) => { leafRef.current = node; slide.attach(node) }}——内联箭头每次渲染都是新函数,React 于是在每次提交都回收再交回这个 ref,attach 里的 rAF 就把 shown 又翻成 true。后果:关的那一帧刚摘掉 is-open,下一帧又被加回来,叶原地不动、280ms 后直接消失(看起来是硬切),藏在叶里的焦点也被抢回去。close.py 采样看得很清楚:Esc 之后 .split 在第 26ms 就又是 split is-open,叶的 translateX 全程 0,到 267ms 直接卸载。改成 ref 回调稳定(useCallbackslide.attach)并「只认第一次挂载的节点」,关闭才真的往外滑(128 → 711,245ms 走完)。

三、close() 里先 navigate() 再读 ref。 那次渲染已经把 ref 摘成 null(叶不再是这一条), 于是 row.focus() 落空、焦点掉到 body 上。改成先把那一行取住、再 navigate、再 focus。 用 HTMLElement.prototype.focus 打桩抓栈才看清:close() 的 focus 确实打到了那一行,紧接着又被 上面第二条那个 effect 的 leaf.focus() 抢走——两个毛病叠在一起,只修一个都看不出来。

验证

  • npm run lintnpx tsc -bnpm test(53/53)全过。
  • 本地预览(npm run build && npm run preview:local -- --port=4199)实测:上表两组几何、控制台 0 error、 prefers-reduced-motion: reduce 下两段过渡为 none、深链 ?m=daily&n=…&d=… 直接出叶并把焦点给叶、 换日期自动收叶、Esc 关闭后地址栏只剩 ?m=daily&d=… 且焦点回到 row row-notearia-expanded=false)。
  • 平滑性的证据分两处:把 --slide 临时拉长 7 倍(缓动曲线不变)后用 rAF 采样,叶的 translateX 713 → 0 与左栏 1556 → 844 同步、连续、单调、带减速;再在真实时长下抓中间帧(就是上面第 6 张图, 抓到时 m41 = 624/713)。踩过的坑:一开始想用 rAF 直接采样真实的 280ms 动画来判断平滑性, headless 下采样读数会跳变——那是测量假象,不能用它下结论,换成「拉长时长采样 + 抓中间帧」才对。
  • 回退说明:本机没有 Browser 插件,按 frontend-testing-debugging 的回退规矩改用 Python Playwright + 本机 Chromium(~/.cache/ms-playwright/chromium-1134/)跑本地预览。预览的身份是伪造的, 所以这批结论里没有任何一条是关于鉴权、会话或 OAuth 的;布局、渲染与交互的结论成立。

复查时又修的两处(都是「正文贴到边缘」)

落地之后按「有没有东西贴到边缘」把四个模块、五档宽度(375 / 430 / 900 / 1280 / 1920)扫了一遍, 量出两处同类的毛病,都出在我给画布写的那条 padding-right: 0 上:

一、叶里的正文贴到屏幕右边缘。 .leaf .page-canvas 把画布的左右留白都清零后,712 的叶正好被 「页边 72 + 正文 640」塞满——1920 上实测 h1 的右边缘 = 1920,标题的最后一个字顶在屏幕边上。 原型里不是这样:叶内正文 600 + 右侧留白 40(h1 1280–1880)。宽屏把 40 补回去,实测与原型 逐像素一致。

二、窄屏的右留白也被清零了。 padding-right: 0 的用意是「宽屏时右留白让给画布」(画布自己 有 40),但窄屏画布不留白,这条规则却仍在生效:430 上实测正文 16 – 414,左边有 16、右边贴屏。 而改动前的线上是两边各 16(16 – 398)。拿原型对照才看清责任:原型的 layout=current (改前的线上)是 16 / 16,layout=proposed 是 16 / 0——这个毛病是从原型里带进来的, 不是实现时写坏的。窄屏把 16 还给 .content

修之前:标题的最后一个字顶在屏幕右边缘(叶内正文 640,右边 0)

修之后:叶内正文 600 + 右侧留白 40,与原型一致

窄屏 430:左右各 16,正文 398(修之前是左 16 / 右 0)

顺带核对过、结论是「不用改」的:叶与窄屏里的正文图片都按度量缩放(640 / 600 / 398), scrollWidthclientWidth 相等,没有横向溢出;375 宽度下正文比视口宽 10px,与改动前 一模一样,是那个宽度的既有状态。

留下的边界

  • 轨道只收 h2/h3,不做折叠;叶里不放轨道(叶自己不够宽)。
  • 不支持同时开两篇笔记;窄屏没有手势关闭,只有「关闭」与 Esc。
  • SLIDE_MS(TSX,280)与 --slide(CSS)是同一条时长的两份拷贝,改一个必须改另一个
  • 门槛用容器查询:不支持 @container 的浏览器退回「只居中、不出轨道」,不会坏在别处。
  • 叶里的正文是 600(不是 640),右留白 40 是定值(ADR 0013 里记着),别当成「少了 40」去修。