fix(shell): chip person picker on every secondary page; one background after 新建对话 (BUG-1532, BUG-1533)

BUG-1532: SecondaryPageShell's default person entry was the one-line
inline switcher; only /chart passed variant="chip". The shell now
defaults to the chip, so /rectification, /ephemeris, /reports, /people
and /annual show the approved two-line chip (avatar initial, name, 本人,
date time · place, chevron; popover on desktop, drawer at <=640px,
「选择星盘」/「共 N 人」). The rectification page's own logic is unchanged.

BUG-1533: until Home registered (it waits for the account) the layout
fell back to "chat-panel secondary-panel" on "/" too. That painted the
pane --color-canvas-soft under the transparent starter composer while
.conversation stayed --color-canvas (the seam), and made Home's chart
frame, which holds the header, an overflow-y:auto box. The fallback is
now path-based (shellInsetClassName): Home gets chat-panel, other routes
chat-panel secondary-panel. Sidebar hover and the waiting 新建对话 row
use a half wash so only the current row carries the full one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-03 18:21:28 +08:00
co-authored by Claude Opus 5.5
parent 9779e195a4
commit 0013a48d7e
11 changed files with 249 additions and 13 deletions
+7
View File
@@ -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)
- 侧栏:「生时校正」「婚恋合盘」从第一帧就在,按产品注册表的默认值(开)显示,账户说关了才藏,不再在账户到了以后插进列表中间。账户一读到就提交(开关、底部头像不再等会话列表)。「最近」在会话没到时是一块固定高度的空白,不再是一行「对话列表读取中」,也不是骨架。
+35
View File
@@ -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)只在星盘页显式传了 `<CurrentSubjectSwitcher variant="chip" />`;`SecondaryPageShell` 的默认入口仍是 `<CurrentSubjectSwitcher />`,组件默认值是一行的 `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),未推送,待合并。
+7
View File
@@ -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、适配器诊断成功)。
+5 -2
View File
@@ -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 }) {
)}
>
<SidebarInset className="shell-inset" inert={registration?.insetInert}>
<PageSwitch className={registration?.insetClassName ?? "chat-panel secondary-panel"}>
{/* Home is never dressed as a secondary page while it registers (BUG-1533). */}
<PageSwitch className={shellInsetClassName(registration?.insetClassName, pathname)}>
{children}
</PageSwitch>
</SidebarInset>
+8 -5
View File
@@ -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); }
@@ -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({
<SecondaryHeader
title={title}
note={note}
besideTitle={besideTitle ?? <CurrentSubjectSwitcher />}
besideTitle={besideTitle ?? <CurrentSubjectSwitcher variant="chip" />}
reserveNote
actions={actions}
/>
+3 -3
View File
@@ -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> | boolean;
+19
View File
@@ -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;
}
@@ -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(<RectificationEntryPage />, (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(<RectificationEntryPage />, (path) => {
if (path === BOOT_ACCOUNT_PATH) return ok(account());
@@ -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("<strong>生时校正</strong>") < 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 : <span \/>;/);
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, /<SecondaryPageShell/, page);
assert.doesNotMatch(source, /CurrentSubjectSwitcher|SubjectSwitcher/, `${page} must not bring its own picker`);
}
assert.match(read("../src/components/secondary-page-shell.tsx"), /besideTitle \?\? <CurrentSubjectSwitcher variant="chip" \/>/);
// Synastry is about two people; its header carries only the title.
assert.match(read("../src/components/synastry/synastry-picker.tsx"), /<SecondaryHeader title="婚恋合盘" \/>/);
});
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, /<PageSwitch className=\{shellInsetClassName\(registration\?\.insetClassName, pathname\)\}>/);
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);
}
});
@@ -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");