diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0b4131..03daf0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-11-03 — 次级页顶栏统一用两行人物胶囊;新建对话后首页一种底色(BUG-1532、BUG-1533) + +- 生时校正、星历、我的报告、星盘档案、年运的顶栏人物入口改成和星盘页同一颗两行胶囊:头像首字、名字和「本人」,第二行日期时间和短地名,桌面弹出、手机是底部抽屉,标题「选择星盘」「共 N 人」。页面标题仍在左边,生时校正页自己的内容没有改(BUG-1532)。 +- 从次级页点「新建对话」回首页:首页在等账户的那一小段不再套次级页的样式,整屏一种底色,不再上浅下深,包着顶栏的那层也不再能滚动(BUG-1533)。 +- 侧栏:悬停的行、等着进首页的「新建对话」改成半块浅底,只有当前页那一行是整块底色加短条,不会看起来有两个当前项。 +- 没有数据库变更。Skill 版本不变。 + ## 2026-11-03 — 加载:侧栏不跳、生时校正和婚恋合盘进来不先转圈(BUG-1526 至 BUG-1529) - 侧栏:「生时校正」「婚恋合盘」从第一帧就在,按产品注册表的默认值(开)显示,账户说关了才藏,不再在账户到了以后插进列表中间。账户一读到就提交(开关、底部头像不再等会话列表)。「最近」在会话没到时是一块固定高度的空白,不再是一行「对话列表读取中」,也不是骨架。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9a491728..b0b370bb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20997,3 +20997,38 @@ - 关联记录:BUG-1403(开关未返回不闪选人页)、BUG-1478(次级页重复读账户和人物)、BUG-1395(开关只从账户来)、BUG-1397(合盘页静态外壳,未改)、BUG-1526。 - 复发自:无 - 修复版本:分支 `codex/loading-1103`(基于 `origin/staging` ae1e9ada),待合并。 + +## BUG-1532 | 生时校正等次级页顶栏的人物还是一行文字胶囊,不是星盘页那颗两行人物胶囊 + +- 状态:resolved(代码 + 回归测试 + 本机构建截图;待部署,登录态真机未测) +- 首次发现 / 最近更新:2026-11-03(产品负责人 staging 截图)/ 2026-11-03 +- 影响面:`frontend/src/components/secondary-page-shell.tsx`(共用顶栏的默认人物入口);受影响页面 `/rectification`、`/ephemeris`、`/reports`、`/people`、`/annual`。`/chart` 原本就对;`/synastry` 是两个人的页面,顶栏只有标题,不放人物入口;个人报告阅读页的顶栏本来就没有人物入口。生时校正页自己的逻辑(BUG-1528 状态卡、BUG-1516 新建一次)没有动。 +- 现象:生时校正顶栏写「名字 · 日期 时间 · 省 · 市 · 区 ⌄」一整行;星盘页(产品确认的 11-02 选人原型)是头像首字、名字、「本人」标签,第二行日期时间和短地名,再一个箭头。 +- 触发条件:打开任何一个用共用顶栏、又没有自己传人物入口的次级页。 +- 根因:11-02 改版(提交 1cd73bc8)只在星盘页显式传了 ``;`SecondaryPageShell` 的默认入口仍是 ``,组件默认值是一行的 `inline`,其余次级页都落在默认值上。当时的测试只断言星盘页源码与 `CurrentSubjectSwitcher` 的两种写法,没有覆盖共用顶栏的默认值。 +- 修复:`SecondaryPageShell` 的默认人物入口改成 `variant="chip"`,所有次级页拿到同一颗胶囊:同样的样式(顶栏变量 `--chat-header-pill` 34px,`::before` 上下各 5px 补到 44px 点击区)、桌面 Popover / ≤640px Drawer(`PICKER_SHEET_QUERY`)、标题「选择星盘」与「共 N 人」。页面标题仍在胶囊左边。没有加新的样式规则。 +- 验证:新文件 `frontend/tests/shell-picker-new-chat-20261103.test.tsx` 三条(共用顶栏渲染出 `is-chip`、头像、名字、箭头且没有一行的 detail,标题在胶囊左边;五个次级页都不自带人物入口而用共用顶栏,`/synastry` 只有标题;胶囊高度与 44px 点击区规则);`loading-shell-20261103.test.tsx` 新增「BUG-1532: the header shows the person as the chart page's two-line chip」(真实 `RectificationEntryPage` 挂在外壳里)。改前源码上其中 3 条失败(胶囊高度规则那条原本就成立)。本机 `next build --webpack` + `next start` + 无头 Chrome(接口全部本地虚构)1280 / 390、深浅两色:生时校正、星历、我的报告、星盘档案的胶囊与星盘页同为 209×34,`::before` 上下 −5px;390 下点开是底部抽屉,标题「选择星盘」「共 2 人」。 +- 防复发:上面的测试。规则:顶栏人物入口只认共用顶栏的默认值,次级页不各自传;改选人样式时连共用顶栏的默认值一起断言。 +- 关联记录:BUG-1511(同一颗胶囊在手机顶栏被藏)、BUG-1470(顶栏控件高度);2026-11-02 选择星盘改版(1cd73bc8)。 +- 复发自:无(11-02 改版时漏掉的覆盖面) +- 修复版本:分支 `codex/shell-fixes-1103`(基于 `codex/loading-1103` 的合并提交 9779e195),未推送,待合并。 + +## BUG-1533 | 从次级页点「新建对话」后首页上下两种底色、顶栏像被往上推,侧栏看着有两个当前项 + +- 状态:resolved(两种底色:本机复现并修复;顶栏偏移与侧栏第二个灰块:本机未复现,按根因修掉可能的来源,待产品真机确认) +- 首次发现 / 最近更新:2026-11-03(产品负责人 staging 截图,深色桌面)/ 2026-11-03 +- 影响面:`frontend/src/app/(app)/layout.tsx`(主区外框的类名)、新增 `frontend/src/lib/shell-inset-class.ts`、`frontend/src/app/globals.css`(侧栏行悬停 / 等待底色)。首页本身、`shell-motion.tsx`、暖返回逻辑没有改。 +- 现象:一、点「新建对话」回到空白首页,顶栏到问候语「又见面了,…」是一块较浅的灰,从输入框往下是更深的底色,中间一条横缝。二、顶栏只露出下半截(人物胶囊、点数、右栏开关都被切掉上半)。三、侧栏「新建对话」是当前项的样子,「星盘」同时有一块灰底。 +- 触发条件:在星盘等次级页点侧栏「新建对话」(或任何客户端导航回 `/`),首页还在等账户、还没向外壳登记自己的那段时间;接口越慢这段越长。本机接口延迟 1.2 秒时约 1.5 秒,150 ms 时约 0.3 秒。 +- 根因: + 1. 外壳给主区外框的类名是 `registration?.insetClassName ?? "chat-panel secondary-panel"`。首页要等 `hydrated && account` 才登记 `chat-panel`,在此之前首页套着次级页的 `secondary-panel`:外框底色变成 `--color-canvas-soft`(深色 #1a1a19),而首页的 `.conversation` 自己是 `--color-canvas`(#262624),输入框所在的 `.composer-wrap-starter` 和下面的 `.starter-balance` 是透明的,于是上浅下深。无头 Chrome 量到:改前 400–1200 ms 外框类名是 `chat-panel secondary-panel`、输入框下方取色 rgb(26, 26, 25),上方 rgb(38, 38, 36)。 + 2. 同一段时间里 `.secondary-panel > :not(.chat-header)` 把首页唯一的子元素 `.chat-chart-frame`(顶栏也在里面)变成 `overflow-y: auto` 的滚动框。`.chat-panel` 用 `overflow: clip` 就是为了让顶栏不会被 `focus()` 之类滚走,这个滚动框绕开了它。本机 1280×800、1024×1290 这框内容没有超出、没滚动,产品截图里顶栏被切掉约 16px 没能复现;这是能让顶栏离开顶部的唯一一层,按此修掉。 + 3. 侧栏:悬停行和 BUG-1509 等待中的「新建对话」用的是 `--sidebar-accent` 整块底色,和当前项的底色一模一样。本机真鼠标点击 + 逐帧截图只有一个当前项(`data-active` 与 `.sidebar-nav-current` 各一个),「星盘」的灰底最可能是指针停在上面的悬停,看上去就是第二个当前项。 +- 修复: + 1. `shellInsetClassName(registered, pathname)`:`/` 上没登记时用首页自己的 `chat-panel`,登记了用登记的;其他路径一律 `chat-panel secondary-panel`(只有首页会登记,留在别的路径上的登记不再生效)。外壳用 `usePathname()` 取路径。首页从第一帧起就是一种底色,`.chat-chart-frame` 也不再是滚动框。 + 2. 悬停与等待改成半块浅底 `--sidebar-hover-wash`(`color-mix(in srgb, var(--sidebar-accent) 50%, transparent)`),当前项保留整块底色、描边和左侧短条,任何时候只有一行看着是当前。 +- 验证:`frontend/tests/shell-picker-new-chat-20261103.test.tsx` 四条:外框类名(`/` 未登记 / 已登记 / 次级页 / 残留登记)与布局源码;空白首页只有一种底色(`.chat-panel`、`.conversation` 同为 `--color-canvas`,`.composer-wrap-starter` 透明、`.starter-balance` 与 `.welcome` 无底色,`.chat-panel` 为 `clip`、`.chat-chart-frame` 不设 overflow);八个路径上侧栏都恰好一个 `data-active` 和一个 `.sidebar-nav-current`;悬停 / 等待用半块底色。改前源码上类名与悬停两条失败。本机生产构建 + 无头 Chrome(接口延迟 1.2 秒,`/` → `/chart` → 「新建对话」)1280 / 390 深浅两色:改后 800 ms 时外框已是 `chat-panel`,输入框上下取色相同(深色 rgb(38, 38, 36)、浅色 rgb(251, 250, 247)),顶栏 y=0,没有元素 `scrollTop > 0`,侧栏一个当前项。 +- 防复发:上面的测试。规则:外壳给主区的默认类名按路径给,首页永远不套次级页的类;不要让包着顶栏的元素变成滚动框;悬停 / 等待的底色不能和当前项相同。 +- 关联记录:BUG-1509、BUG-1040、BUG-1104(「新建对话」暖返回,未改)、BUG-1013(焦点滚动面板)、BUG-1526(侧栏行从第一帧就在)。 +- 复发自:无 +- 修复版本:分支 `codex/shell-fixes-1103`(基于 9779e195),未推送,待合并。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 336fd9be..b5d74796 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,13 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-11-03 · 次级页人物胶囊与新建对话底色(BUG-1532、BUG-1533,worktree `/workspace/wt-shell`) + +- `pre_work_check.py` 用 `/workspace/wt-153/.venv-annual` 跑,exit 0。 +- 截图脚本的虚构账户资料要写成账户接口的 snake_case 行(`birth_date`、`birth_place_label` …,同 `loading-shell-20261103.test.tsx`);写成 camelCase 时 `readProfile` 读不出完整资料,人物胶囊只剩名字,看上去像样式问题,其实是假数据不对。 +- 停本机 `next start` 要按端口查 pid 再 `kill`,不要 `pkill -f "next start …"`:模式会匹配到执行这条命令的 shell 自己,命令被杀(exit 144),服务还在。 +- 无头 Chrome 第一次打开偶发停在 `/terms`(页面没换),同一条单独重跑正常,和 11-03 星盘分盘那轮记录的一样,按偶发处理。 + ## 2026-11-03 · staging 加载问题(BUG-1526 至 BUG-1529,worktree `/workspace/wt-loading`) - `pre_work_check.py` 用 `/workspace/wt-153/.venv-annual` 跑,exit 0,六项全部 ok(remote verified、适配器诊断成功)。 diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 19e671ba..82c09b20 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -2,7 +2,7 @@ import { useEffect, type ReactNode } from "react"; import dynamic from "next/dynamic"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { AccountDeletionGate } from "@/components/account-deletion-gate"; import { AppSidebar } from "@/components/app-sidebar"; @@ -15,6 +15,7 @@ import { useShellSessionActions } from "@/hooks/use-shell-session-actions"; import { navigateAppPath, registerShellNavigate } from "@/lib/app-navigation"; import { sessionHref } from "@/lib/chat-session-url"; import { OnboardingJourneyProvider } from "@/lib/onboarding-journey"; +import { shellInsetClassName } from "@/lib/shell-inset-class"; import { shellProductEnabled } from "@/lib/shell-product-flags"; import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list"; import "../site-styles"; @@ -38,6 +39,7 @@ function AppShell({ children }: { children: ReactNode }) { const compatibilityEnabled = shellProductEnabled(list.account, "compatibility"); const rectificationEnabled = shellProductEnabled(list.account, "rectification"); const router = useRouter(); + const pathname = usePathname(); useEffect(() => { registerShellNavigate((href) => router.push(href)); return () => registerShellNavigate(null); @@ -67,7 +69,8 @@ function AppShell({ children }: { children: ReactNode }) { )} > - + {/* Home is never dressed as a secondary page while it registers (BUG-1533). */} + {children} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a7c1cdf9..58909202 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -899,8 +899,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i .brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); } .auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); } .sidebar-nav-link[data-nav="new"] { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; } -.sidebar-nav-link { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; } -.sidebar-nav-link:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.sidebar-nav-link { --sidebar-hover-wash: color-mix(in srgb, var(--sidebar-accent) 50%, transparent); width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; } +/* BUG-1533: a hovered or waiting row is a half wash, never the current row's full + one, so exactly one row reads as current (the pointer resting on 星盘 after + 「新建对话」 looked like a second active item). */ +.sidebar-nav-link:hover { background: var(--sidebar-hover-wash); color: var(--sidebar-accent-foreground); } .sidebar-nav-link[data-active="true"] { position: relative; background: transparent; @@ -920,9 +923,9 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i /* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the action color scarce, so it stays tinted text on a transparent row rather than a filled block — no page-scale color surface. */ -.sidebar-nav-link[data-nav="new"]:hover { background: var(--sidebar-accent); color: var(--color-action-hover); } +.sidebar-nav-link[data-nav="new"]:hover { background: var(--sidebar-hover-wash); color: var(--color-action-hover); } /* BUG-1509: off `/`, 「新建对话」 may wait a moment for the home snapshot; the page stays put, the link says it heard the click. */ -.sidebar-nav-link[aria-busy="true"] { cursor: progress; background: var(--sidebar-accent); } +.sidebar-nav-link[aria-busy="true"] { cursor: progress; background: var(--sidebar-hover-wash); } .sidebar-nav-link[data-nav="new"] svg, .sidebar-nav-link svg { width: 18px; @@ -2303,7 +2306,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); } @media (hover: hover) { - .sidebar-nav-link[data-nav="new"]:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); } + .sidebar-nav-link[data-nav="new"]:not(:disabled):hover { background: var(--sidebar-hover-wash); color: var(--color-action-hover); } .composer button:not(:disabled):hover { background: var(--color-action-hover); } .button-primary:not(:disabled):hover, .composer .composer-row-end > button:not(:disabled):hover { background: var(--color-action-fill-hover); } .chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); } diff --git a/frontend/src/components/secondary-page-shell.tsx b/frontend/src/components/secondary-page-shell.tsx index 5a7cc552..d08de213 100644 --- a/frontend/src/components/secondary-page-shell.tsx +++ b/frontend/src/components/secondary-page-shell.tsx @@ -16,7 +16,12 @@ export type SecondaryPageShellProps = { }; /** - * Shared chrome for /chart, /ephemeris and /reports. + * Shared chrome for the secondary pages (/chart, /rectification, /ephemeris, + * /reports, /people, /annual). + * + * The person picker is the top bar's two-line chip on every one of them, the + * same face and behaviour as the chart page and the chat (BUG-1532: the others + * still showed the one-line 「name · date · place」 pill). * * Header and body stay siblings of `.chat-panel` so the existing 46px + 1fr * grid still owns height. The body cell (`.secondary-page`) always fills the @@ -36,7 +41,7 @@ export function SecondaryPageShell({ } + besideTitle={besideTitle ?? } reserveNote actions={actions} /> diff --git a/frontend/src/components/subject-switcher.tsx b/frontend/src/components/subject-switcher.tsx index 177c2626..baf5ec88 100644 --- a/frontend/src/components/subject-switcher.tsx +++ b/frontend/src/components/subject-switcher.tsx @@ -64,9 +64,9 @@ export type SubjectSwitcherProps = { readonly requestInFlight?: boolean; readonly deleted?: boolean; /** - * `chip` (the chat top bar, 2026-11-02): an initial, the name and its relation - * tag on the first line, 「date time · city」 on the second. `inline` (the - * secondary pages) keeps the one-line 「name · date time · city」. + * `chip` (every top bar, 2026-11-02; the secondary pages since BUG-1532): an + * initial, the name and its relation tag on the first line, 「date time · + * city」 on the second. `inline` is the one-line 「name · date time · city」. */ readonly variant?: "inline" | "chip"; readonly onChoose: (row: ChatProfilePickerRow) => Promise | boolean; diff --git a/frontend/src/lib/shell-inset-class.ts b/frontend/src/lib/shell-inset-class.ts new file mode 100644 index 00000000..f2725551 --- /dev/null +++ b/frontend/src/lib/shell-inset-class.ts @@ -0,0 +1,19 @@ +/** + * The main pane's class before (or without) a page registering its own. + * + * Home registers `chat-panel` once it has an account; until then the layout + * used `chat-panel secondary-panel` on every route. On `/` that put the + * secondary-page rules on Home (BUG-1533): the pane turned `--color-canvas-soft` + * under the transparent starter composer while `.conversation` kept + * `--color-canvas`, a seam across the screen, and Home's chart frame became an + * `overflow-y: auto` box that could scroll its own 46px header out of view. + * Home's fallback is therefore Home's own class. Only Home registers one, so + * a registration still standing while another route renders is ignored too. + */ +export const HOME_INSET_CLASS = "chat-panel"; +export const SECONDARY_INSET_CLASS = "chat-panel secondary-panel"; + +export function shellInsetClassName(registered: string | null | undefined, pathname: string | null | undefined): string { + if ((pathname ?? "/") !== "/") return SECONDARY_INSET_CLASS; + return registered || HOME_INSET_CLASS; +} diff --git a/frontend/tests/loading-shell-20261103.test.tsx b/frontend/tests/loading-shell-20261103.test.tsx index 07915581..b45ea6a0 100644 --- a/frontend/tests/loading-shell-20261103.test.tsx +++ b/frontend/tests/loading-shell-20261103.test.tsx @@ -259,6 +259,28 @@ describe("/rectification first paint (BUG-1528)", { concurrency: 1 }, () => { } }); + test("BUG-1532: the header shows the person as the chart page's two-line chip", async () => { + const mounted = await mountInShell(, (path) => { + if (path === BOOT_ACCOUNT_PATH) return ok(account()); + if (path === BOOT_CHART_PROFILES_PATH) return ok({ profiles: [] }); + return "never"; + }); + try { + await waitFor(mounted.harness, () => mounted.text().includes("合成甲")); + const trigger = mounted.harness.elements().find((element) => /chat-profile-picker-trigger/.test(element.getAttribute("class") ?? "")); + assert.ok(trigger, mounted.harness.errors.map(String).join("\n")); + assert.match(trigger.getAttribute("class") ?? "", /\bis-chip\b/); + const withClass = (name: string) => mounted.harness.elements().filter((element) => (element.getAttribute("class") ?? "").split(/\s+/).includes(name)); + assert.equal(withClass("chat-profile-picker-avatar").length, 1, "avatar initial"); + assert.equal(withClass("chat-profile-picker-full-name").length, 1, "name on line 1"); + assert.equal(withClass("chat-profile-picker-detail").length, 0, "not the one-line pill"); + const title = mounted.harness.elements().find((element) => element.tagName === "STRONG" && element.textContent === RECTIFICATION_PAGE_COPY.title); + assert.ok(title, "the page title stays in the header"); + } finally { + await mounted.close(); + } + }); + test("a summary Home already read fills the card at once and is revalidated", async () => { const mounted = await mountInShell(, (path) => { if (path === BOOT_ACCOUNT_PATH) return ok(account()); diff --git a/frontend/tests/shell-picker-new-chat-20261103.test.tsx b/frontend/tests/shell-picker-new-chat-20261103.test.tsx new file mode 100644 index 00000000..cb7c77f2 --- /dev/null +++ b/frontend/tests/shell-picker-new-chat-20261103.test.tsx @@ -0,0 +1,134 @@ +/** + * Staging report, 2026-11-03 (BUG-1532, BUG-1533). + * + * - Every secondary page that shows a person uses the chart page's two-line + * chip (avatar initial, name, 本人, date time · place, chevron), not the + * one-line 「name · date · place」 pill. + * - After 「新建对话」 from a secondary page, Home is never dressed as a + * secondary page while it registers: one background, no scroll box around + * its header. + * - Exactly one sidebar row looks current; hover is a lighter wash. + * + * Fictional data only. + */ +import "./install-test-document"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PathnameContext } from "next/dist/shared/lib/hooks-client-context.shared-runtime"; + +import { AppSidebar } from "../src/components/app-sidebar.tsx"; +import { SecondaryPageShell } from "../src/components/secondary-page-shell.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { HOME_INSET_CLASS, SECONDARY_INSET_CLASS, shellInsetClassName } from "../src/lib/shell-inset-class.ts"; +import { cssDeclarations } from "./css-contract-test-support.ts"; + +Object.assign(globalThis, { React }); + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const css = read("../src/app/globals.css"); + +function renderShell(title: string) { + return renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SecondaryPageShell, { title }))); +} + +test("BUG-1532: the shared secondary header shows the person as the two-line chip, title to its left", () => { + const markup = renderShell("生时校正"); + assert.match(markup, /class="chat-header-chart chat-profile-picker-trigger is-chip"/); + assert.match(markup, /chat-profile-picker-avatar/); + assert.match(markup, /chat-profile-picker-full-name/); + assert.match(markup, /chat-profile-picker-chevron/); + assert.doesNotMatch(markup, /chat-profile-picker-detail/, "no one-line 「· date · place」 pill"); + assert.ok(markup.indexOf("生时校正") < markup.indexOf("is-chip"), "the page title stays left of the chip"); +}); + +test("BUG-1532: /rectification, /ephemeris, /reports, /people and /annual take the shell's chip; /synastry shows no person", () => { + // Signed in, the rectification page passes nothing and gets the shell's chip. + assert.match(read("../src/components/rectification-page/rectification-entry-page.tsx"), /const beside = signedIn \? undefined : ;/); + for (const page of [ + "../src/components/rectification-page/rectification-entry-page.tsx", + "../src/components/ephemeris/ephemeris-page.tsx", + "../src/components/personal-report/personal-report-center.tsx", + "../src/components/people/people-page.tsx", + "../src/components/annual/annual-report-page.tsx", + ]) { + const source = read(page); + assert.match(source, //); + // Synastry is about two people; its header carries only the title. + assert.match(read("../src/components/synastry/synastry-picker.tsx"), //); +}); + +test("BUG-1532: the chip is drawn at the top-bar pill height with a 44px hit area", () => { + assert.match(cssDeclarations(".chat-profile-picker-trigger.is-chip", css), /height: var\(--chat-header-pill\); min-height: var\(--chat-header-pill\)/); + assert.match(css, /\.chat-header \{ --chat-header-pill: 34px;/); + // 34px + 5px above + 5px below = 44px. + assert.match(cssDeclarations(".chat-profile-picker-trigger.is-chip::before", css), /position: absolute; inset: -5px 0; content: "";/); +}); + +test("BUG-1533: Home's pane class never falls back to the secondary-page class", () => { + assert.equal(shellInsetClassName(null, "/"), HOME_INSET_CLASS, "Home before it registers"); + assert.equal(shellInsetClassName(undefined, "/"), "chat-panel"); + assert.equal(shellInsetClassName("chat-panel is-rectification", "/"), "chat-panel is-rectification"); + assert.equal(shellInsetClassName(null, "/chart"), SECONDARY_INSET_CLASS); + assert.equal(shellInsetClassName("chat-panel", "/rectification"), "chat-panel secondary-panel", "a registration left over from Home is ignored elsewhere"); + assert.doesNotMatch(HOME_INSET_CLASS, /secondary-panel/); + const layout = read("../src/app/(app)/layout.tsx"); + assert.match(layout, //); + assert.doesNotMatch(layout, /\?\? "chat-panel secondary-panel"/); +}); + +test("BUG-1533: the empty new chat has one background and no scroll box around its header", () => { + // The conversation and the pane are the same canvas; the starter composer and + // the balancing row under it add no layer of their own. + assert.match(cssDeclarations(".chat-panel", css), /background: var\(--color-canvas\)/); + assert.match(cssDeclarations(".conversation", css), /background: var\(--color-canvas\)/); + assert.match(cssDeclarations(".composer-wrap-starter", css), /background: transparent;\s+backdrop-filter: none;/); + assert.doesNotMatch(cssDeclarations(".starter-balance", css), /background/); + assert.doesNotMatch(cssDeclarations(".welcome", css), /background/); + // The pane clips instead of scrolling, and Home's frame is no scroll container, + // so the 46px header cannot be scrolled off the top. + assert.match(cssDeclarations(".chat-panel", css), /overflow: clip/); + assert.doesNotMatch(cssDeclarations(".chat-chart-frame", css), /overflow/); + // The darker canvas and the scroll rule belong to the secondary pages only. + assert.match(cssDeclarations(".secondary-panel", css), /background: var\(--color-canvas-soft\)/); +}); + +function renderSidebarAt(pathname: string) { + return renderToStaticMarkup(createElement( + PathnameContext.Provider, + { value: pathname }, + createElement(SidebarProvider, null, createElement(AppSidebar, { + sessions: [], activeSessionId: null, account: null, settled: true, compatibilityEnabled: true, rectificationEnabled: true, + })), + )); +} + +test("BUG-1533: exactly one sidebar row is current on every shell route", () => { + for (const pathname of ["/", "/chart", "/rectification", "/ephemeris", "/reports", "/synastry", "/people", "/people/new"]) { + const markup = renderSidebarAt(pathname); + const active = markup.match(/data-active="true"/g) ?? []; + const washes = markup.match(/class="sidebar-nav-current"/g) ?? []; + assert.equal(active.length, 1, `${pathname}: one data-active row`); + assert.equal(washes.length, 1, `${pathname}: one current wash`); + } +}); + +test("BUG-1533: a hovered or waiting row is a half wash, never the current row's full one", () => { + assert.match(cssDeclarations(".sidebar-nav-current", css), /background: var\(--sidebar-accent\)/); + assert.match(cssDeclarations(".sidebar-nav-link", css), /--sidebar-hover-wash: color-mix\(in srgb, var\(--sidebar-accent\) 50%, transparent\)/); + for (const selector of [ + ".sidebar-nav-link:hover", + '.sidebar-nav-link[data-nav="new"]:hover', + '.sidebar-nav-link[data-nav="new"]:not(:disabled):hover', + '.sidebar-nav-link[aria-busy="true"]', + ]) { + const body = cssDeclarations(selector, css); + assert.match(body, /background: var\(--sidebar-hover-wash\)/, selector); + assert.doesNotMatch(body, /background: var\(--sidebar-accent\)/, selector); + } +}); diff --git a/frontend/tests/subject-switcher-150-20261028.test.tsx b/frontend/tests/subject-switcher-150-20261028.test.tsx index c5886917..f67dba92 100644 --- a/frontend/tests/subject-switcher-150-20261028.test.tsx +++ b/frontend/tests/subject-switcher-150-20261028.test.tsx @@ -19,7 +19,8 @@ import { // Issue #150: the person switcher says when and where, carries 编辑 / 删除, and // also sits on top of the chat-side chart. Every person here is made up. // 2026-11-02: the chart-side copy is gone (header only), and the chat header uses -// the two-line chip variant; the secondary pages keep the one-line trigger. +// the two-line chip variant. 2026-11-03 (BUG-1532): every secondary page header +// takes the chip too; the one-line `inline` face is only the component's default. const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); const switcherSource = read("../src/components/subject-switcher.tsx");