feat(ui): 星盘 tab 下划线化、参数收成键值表,星历日期条改 ‹ 日期 ›
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped

星盘页(T5.1):
- 五个体系 tab 从五枚描边药丸改成顶栏下方一条下划线式 tab 行,
  flex-wrap: nowrap + 容器自身横滚,375px 下仍是一行;底线画在滚动
  容器的 border 上,不随内容滚走。眉标(BUG-717 的成本与速度承诺)
  原文保留,位置从 tab 行上方移到下方,让 tab 行直接接住 46px 顶栏。
- 分盘 chips 保留但降一级:去描边去卡面、caption 字号、三级色,
  当前项只留浅底。它是一个 tab 内部的筛选器,不是第二层导航。
- 参数从「中宫叠卡(手机两行/桌面六行)+ 盘下重复卡 + media query
  三选一」收成一张八行键值表。同一组事实原本画三遍,改一处要改三处。
  .chart-page-center-card / -compact / -extra / .chart-page-params-below
  / .chart-page-boundary-desktop 五个类连同 767/768 开关块一并删除。
  view.vedic.boundary 那句准确性边界句原文未动,紧跟在表下方,且
  DOM 里从出现两次变成一次。
- 三张行星表(印度宿度 / 西洋相位 / 七政十一曜)全部包上横向滚动
  容器,wrap 补 min-width: 0 —— 少了它 grid item 按内容定宽,表格会
  撑宽页面而不是在自己盒子里滚动。原先只有第一张有 wrap。

星历页(T5.2):
- 日期导航从三个并排等宽按钮改成居中的 ‹ 日期 ›:日期是主语,两枚
  44px 箭头是把手。箭头字形 aria-hidden,可访问名仍是「前一天」
  「后一天」,读屏不丢方向。
- 「今天」只在非今日时以文字按钮出现在日期下方;在今日时整个不渲染,
  而不是渲染成禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。
- 三段统一成同一套卡片:框从内容上移到段落,.ephemeris-panchanga 的
  dl 网格原样保留。收尾句原文不变,但从底部第四张卡降为页尾一行小字。

两条既有断言改动,测试文件内就地写了原值/新值/原因,进度记录成表:
参数断点开关的三条断言换成「参数只有一张键值表且那五个类不再有规则」
(更强,且避开了原写法 indexOf 未命中后静默锚错断点块的陷阱);
「今天」在场的单状态断言拆成今日/非今日两状态并加断不出现 disabled。
测试总数 3350 → 3352,失败清单与基线逐条一致(31 条无 Docker 缺口)。

tsc 0 错;lint 0 error / 118 warning(与基线同数);/ /chart /ephemeris
渲染标记均保持 ○ Static,/reports 仍 ƒ;主 CSS gzip 36,745 → 36,788
(+0.12%)。行星表 375px 横滚、tab 行横滚、读屏播报无真机验收,已在
进度记录里如实写成环境缺口。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:47:50 +00:00
co-authored by Claude Opus 5
parent ca0204984b
commit 1e3570b462
12 changed files with 538 additions and 213 deletions
+53 -14
View File
@@ -24,6 +24,18 @@ const golden = JSON.parse(
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const vedicTab = readFileSync(new URL("../src/components/chart-page/chart-vedic-tab.tsx", import.meta.url), "utf8");
const westernTab = readFileSync(new URL("../src/components/chart-page/chart-western-tab.tsx", import.meta.url), "utf8");
const qizhengTab = readFileSync(new URL("../src/components/chart-page/chart-qizheng-tab.tsx", import.meta.url), "utf8");
/** Every selector that has at least one rule, comments stripped first. */
function declaredSelectors(source: string): Set<string> {
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
const found = new Set<string>();
for (const [, selectorList] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
for (const target of selectorList.split(",")) found.add(target.trim().replace(/\s+/g, " "));
}
return found;
}
const vedicSvg = readFileSync(new URL("../src/components/personal-report/vedic-chart-svg.tsx", import.meta.url), "utf8");
const profile = {
@@ -126,25 +138,52 @@ test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的
assert.match(sidebar, /tooltip="星历"/);
});
test("mobile width keeps two centre lines and moves the full parameter card below the chart", () => {
// 原值:三条断言都以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚,
// 再加一条 `.chart-page-back` 的 44px 触摸目标。
// 新值:直接断那个断点块里的三个开关;`.chart-page-back` 一条删除。
// 原因:hero 与「返回对话」按钮随页面并入 app 外壳而删除(T4.2)。被断言的三个
// 开关本身一条没动,只是不再有 hero 那一行可以用来起锚。返回路径现在是
// 常驻侧栏,它的触摸目标由 sidebar-contract 管
const mobile = globalStyles.slice(
globalStyles.indexOf("@media (max-width: 767px)", globalStyles.indexOf(".chart-page-center-compact")
- 2000),
);
assert.match(mobile, /\.chart-page-center-compact \{ display: block; \}/);
assert.match(mobile, /\.chart-page-center-extra \{ display: none; \}/);
assert.match(mobile, /\.chart-page-params-below \{ display: block; \}/);
test("the parameters are one key/value table, not a per-breakpoint duplicate", () => {
// 原值:断言 `@media (max-width: 767px)` 块里的三个开关
// `.chart-page-center-compact { display: block }` /
// `.chart-page-center-extra { display: none }` /
// `.chart-page-params-below { display: block }`。
// 新值:断言参数只有一张 `.chart-page-params-table` 键值表,且那三个类名在
// 整份 CSS 里都不再作为规则出现
// 原因:T5.1 要求参数从中宫卡与盘下卡收进一张统一键值表(决策记录 D8 一系)。
// 原断言守的是「同一组参数按断点切换三种画法」,那正是本轮要去掉的东西,
// 断言无法原样保留。新断言比原来更强:它不只要求当前画法正确,还禁止
// 任何一种按断点复制参数的写法回来。
// 注:原写法本身也是个陷阱——`indexOf` 未命中时返回 -1`-2000` 之后
// 会从文件开头重新搜 `@media`,静默锚到无关的断点块上。
assert.match(cssDeclarations(".chart-page-param-row", globalStyles), /grid-template-columns/);
for (const dead of [
".chart-page-center-card",
".chart-page-center-compact",
".chart-page-center-extra",
".chart-page-params-below",
".chart-page-boundary-desktop",
]) {
assert.equal(
declaredSelectors(globalStyles).has(dead),
false,
`${dead} still has a CSS rule`,
);
}
assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/);
assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/);
});
test("the planet table scrolls inside its own box instead of widening the page", () => {
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /overflow-x:\s*auto/);
// Without `min-width: 0` a grid item is sized by its content, so the table
// pushes the page sideways and the overflow never engages.
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /min-width:\s*0/);
assert.match(cssDeclarations(".chart-page-planet-table", globalStyles), /min-width:\s*\d/);
// Every planet table is wrapped, not just the one on the 星盘 tab.
for (const source of [vedicTab, westernTab, qizhengTab]) {
const tables = [...source.matchAll(/<table className="chart-page-planet-table">/g)].length;
const wraps = [...source.matchAll(/<div className="chart-page-planet-table-wrap">/g)].length;
assert.equal(wraps, tables);
}
});
test("an incomplete profile page has no loading animation", () => {
const markup = renderToStaticMarkup(React.createElement(ChartPageView, {
view: { status: "birth_profile_incomplete", billed: false, message: "还没有可用来排盘的出生资料。" },
+16 -1
View File
@@ -114,6 +114,22 @@ test("the Lahiri note sits under panchanga and not under transits", () => {
assert.doesNotMatch(transitsBlock, new RegExp(EPHEMERIS_COPY.panchangaLahiriNote));
});
test("今天 is absent on today and present off it, never disabled", () => {
// 原值:`assert.match(markup, new RegExp(EPHEMERIS_COPY.today))` —— 在
// 「copy stays factual」里对唯一一次渲染(date === today)断言「今天」在场。
// 新值:拆成两个状态各断一次,并加断 `disabled` 不出现。
// 原因:T5.2 验收标准要求「在今日时不出现『今天』按钮,而不是出现一个禁用按钮」。
// 原断言恰好只覆盖 date === today 这一种情况,照搬会要求今日也渲染该按钮,
// 与验收标准直接冲突。新断言覆盖两个状态,比原来严格。
const onToday = render(payloadFor("2026-09-15"));
assert.doesNotMatch(onToday, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(onToday, /disabled=""/);
const offToday = render(payloadFor("2026-09-14"));
assert.match(offToday, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(offToday, /disabled=""/);
});
test("mobile CSS turns five panchanga cards into one stacked card", () => {
const markup = render(payloadFor("2026-09-15"));
assert.match(markup, /class="ephemeris-panchanga"/);
@@ -129,7 +145,6 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
assert.match(markup, new RegExp(EPHEMERIS_COPY.ask));
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/);
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");