# PROGRESS · 次级页外壳 R4 任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 R4 执行方式:**直接执行模式** 分支:`codex/cend-shell-20260916` 基线:`origin/staging` = `b3ea8c33`(第一份任务书 R1–R3 合入后) BUG 段:不占用(产品改造) R5(星盘+星历)与 R6(报告)依赖本轮。 --- ## T4.1 · 只读导航外壳 新增三个文件: | 文件 | 职责 | |---|---| | `src/hooks/use-nav-rail.ts` | 两个只读 GET(`/api/sessions?limit=40`、`/api/account`),`Promise.allSettled`,**不含任何写操作** | | `src/components/app-nav-rail.tsx` | 品牌 + 新建对话 + 星盘/星历/我的报告 + 一条「最近」平铺列表 + 身份页脚 | | `src/components/secondary-shell.tsx` | `SidebarProvider` → `AppNavRail` → `SidebarInset.chat-panel.secondary-panel` + 46px 顶栏 | 会话行走 `sessionHref("", id)` → `/?c=`,跳转用 `persistLoginSessionReturn()` + `window.location.assign()`,**与现有侧栏 `leaveChat()` 完全一致的行为**,没有另造一套。 侧栏原语(`SidebarProvider` / `Sidebar` / `SidebarTrigger` / `SidebarRail`)是通用的,抽屉与折叠态直接复用,没有重写。 ### 验收对照 | 任务书验收标准 | 结果 | |---|---| | `AppNavRail` 不 import page.tsx 的 hook,不持有会话写状态 | ✅ 依赖只有 ui/sidebar、`chat-session-url`、`session-groups`、自己的 hook | | 四个路由都挂上外壳,桌面常驻、移动端抽屉 | ⚠️ **三个**:`/chart`、`/ephemeris`、`/reports`。`/reports/[reportId]`(阅读页)留给 R6,见下 | | 点「最近」行跳 `/?c=` 并选中 | ✅ 复用 `sessionHref`;`parseSessionUrlQuery` 的既有测试未动 | | 列表未到达时静态空态,无骨架无 spinner | ✅ 三档文案:未登录 / 已 settled 无数据 / 读取中,全是静态文字 | | 未登录时降级为品牌 + 登录入口,不报错不空白 | ✅ 401 → `signedOut`,列表位显示「登录后可以看到你的对话」,页脚变「去登录」 | | 四个路由渲染标记逐个入记录 | ✅ 见下 | **`/reports/[reportId]` 本轮没挂**:阅读页是纸面文档表面,外壳会改变它的滚动所有权与打印行为,而 R6 正要重做它(加目录)。同一轮里既挂外壳又改阅读结构,出问题难以定位。留给 R6,已在任务书依赖里写明。 ### 两处我自己的健壮性问题,被测试抓到 1. **`usePathname()` 会返回 `null`。** 类型标注是 `string`,但在 app-router 上下文之外(包括直接挂载组件的单测里)返回 null,我的 `pathname.startsWith(...)` 直接抛 `Cannot read properties of null`,打红了 `chart-page-view` 与 `ephemeris-page` 两个渲染测试共 5 条。已加 `?? ""`。既有的 `app-sidebar.tsx` 有同样的写法但没被覆盖到,本轮不动它(红线 11)。 2. **`fetch` 可能不存在**(jsdom / 纯 node 环境)。原写法会在 `load()` 里同步抛出,变成未捕获的 promise 拒绝。已加 `typeof fetch !== "function"` 短路 + `.catch()` 兜底。 ## T4.2 · 删掉三套重复骨架 | 页面 | 删除 | |---|---| | `chart-page-view.tsx` | `.chart-page-shell` / `.chart-page-topbar` / `.chart-page-back`(返回对话)/ `.chart-page-hero` + `

星盘

` | | `ephemeris-page.tsx` | `.ephemeris-shell` / `.ephemeris-topbar` / `.ephemeris-back` / hero 的 `

`;底部「去问」提到顶栏 | | `personal-report-center.tsx` | `.report-center-shell` / `.report-center-topbar` / `.report-center-back` / hero 的 `

`;「生成完整报告」提到顶栏 | CSS 侧删掉 22 条规则、裁剪 6 条选择器列表。**这次用了 R2 教训后的切分规则**(只在注释之后找选择器),改完复核:注释配平 73/73、含花括号的注释 0 条。 ### 一处内容差点被我弄丢 `CHART_VIEW_COPY.eyebrow`(「主盘直接算 · 分盘按需 · 不消耗点数」)原本在 hero 里,随 hero 一起被删。这是 **BUG-717 专门定的文案**——它是成本与速度承诺,而且会印在失败页上。测试打红才发现,已放回 tab 行下方作为一行静默说明。 ### 错误态与空态 三个页面的降级分支(未登录、读取失败、无数据、`natalMissing`、`panchangaUnavailable` 等)全部保留,只是从 `*-shell *-message` 的根节点改成外壳内的一个 `section`,`h1` 降为 `h2`(页名已在顶栏,一页不该有两个 h1)。 ### 「返回对话」残留说明 `git grep 返回对话 src/` 还有 4 处命中,都在**根边界页**(`error.tsx`、`not-found.tsx`、`global-error.tsx`、`reports/[reportId]/not-found.tsx`)。按 `DESIGN.md`,这些页在根 layout 段里、**不得 import `globals.css`**,因此不能挂外壳,那个链接是它们唯一的回家路径。本轮不动。 --- ## 收尾验收 | 红线 | 结果 | |---|---| | 1 · `tsc` 0 错 / `lint` 0 error | ✅ 118 warning,与基线同 | | 2 · 测试总数 | ✅ tests 3350,pass 3303,fail 31,与基线 `diff` **逐条一致** | | 3 · **四个路由渲染标记不变** | ✅ `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ`,改前改后完全相同 | | 4 · 次级页首屏体积 | ⚠️ **口径缺口**:CSS 侧 39,825 → **39,727 字节(−0.25%)**,三套骨架并成一套是净减。但当前 Turbopack 的路由表**不输出 per-route JS 体积**,导航栏与 sidebar 原语给三个次级页增加的 JS 我量不到,不能给数字。任务书预期的「次级页体积上升」无法证实也无法证伪 | | 6 · 一律 `ChatComposer` / 一套滚动跟随 | ✅ 本轮未涉及 | | 7 · 无 spinner / 骨架 | ✅ 列表在途是静态文案 | | 8 · 不回退 BUG-698 的 `@supports (height: 1dvh)` | ✅ 未触碰 | | 9 · 不回退 BUG-616/617 的报告盘面 grid | ✅ 未触碰 `report-chart-grid-rehype.ts` | | 10 · 同提交更新 `DESIGN.md` | ✅ 新增「Secondary page shell」一节;「Personal report centre」的 Structure 与 Width 两条重写 | | 11 · 不升依赖、不留新 warning | ✅ | | 12 · 只读侧栏不发起写操作 | ✅ `use-nav-rail.ts` 只有两个 GET | Python 快速门(BUG-736):本轮**未改** `page.tsx`;新增的 `use-nav-rail.ts` 不是 Home 级 hook(`Home()` 不引用它),按规则不需要登记进 `OPTIONAL_HOME_HOOKS`。仍跑了 `pytest tests/test_supabase_user_data_contract.py`,8 条全绿。 ### 按红线 3 更新的既有断言(原值 / 新值 / 原因) | 文件 | 原值 | 新值 | 原因 | |---|---|---|---| | `chart-page-view` · 移动端 | 三条以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚;另断 `.chart-page-back` 44px | 直接断该断点块里的三个开关;删 `.chart-page-back` 一条 | hero 与返回按钮已删;**被断言的三个开关一条没动** | | `chart-page-view` · 空态 | `assert.match(markup, /返回对话/)` | `assert.match(markup, /新建对话/)` + `doesNotMatch(/返回对话/)` | 返回路径从一次性链接变成常驻侧栏。这条**更强**:要求整条导航在场 | | `personal-report-entry` | `.report-center-shell` 的 height/overflow | `.secondary-panel > :not(.chat-header)` 的 overflow + `.chat-panel` 的 grid 行 | 滚动所有权归外壳;「在高度锁内部滚动、不自己撑高」没变 | | `personal-report-theme-contract` ×2 | `.report-center-back` 44px;标题三处含 `.report-center-hero h1` | 前者删除;后者收为封面 + 章节两处 | 返回按钮与页标题都移进了外壳顶栏,由 chat-header 规则统管 | | `viewport-breakpoint-contract` ×2 | `.report-center-shell` / `.ephemeris-shell` 在 767 块内 | `.report-center-body` / `.ephemeris-body` | 页面根换了;「用全局 767 断点、不自立断点」由同测试的两条 `doesNotMatch` 继续守 | 一条实际收紧(空态从「有返回链接」变成「整条导航在场 + 不得再有返回链接」),其余是形态同步。 --- ## 环境缺口(不得记为通过) 1. **次级页侧栏在移动端抽屉的手势与焦点行为**(本轮新挂,优先级最高)。 2. **未登录时三个次级页的降级表现**——无登录态,只能证明代码分支存在。 3. **从次级页点「最近」行回到对话并正确选中**——需要真实会话数据。 4. **per-route JS 体积**:构建不输出,需要另外的测量手段(见红线 4)。 --- ## 下一轮 R5(星盘 + 星历)与 R6(报告中心 + 报告阅读,含把 `/reports/[reportId]` 挂进外壳)基线取本轮合入后的 `origin/staging`。R7、R8 与本轮文件不重叠,可并行。