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
@@ -74,10 +74,6 @@ export function ChartPageView({
</section>
) : (
<div className="chart-page-body">
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<div className="chart-page-tabs" role="tablist" aria-label="星盘体系">
{CHART_VIEW_TABS.map((item) => (
<button
@@ -92,6 +88,11 @@ export function ChartPageView({
</button>
))}
</div>
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. It reads under the tab row now, because
the tab row is what the header hands off to. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<section className="chart-page-panel" role="tabpanel">
{tab === "vedic" ? (
<ChartVedicTab
@@ -1,31 +0,0 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatCoord } from "./chart-format";
export function ChartParamLines({
view,
compact,
}: {
view: ChartViewOk;
compact?: boolean;
}) {
const { profile } = view;
const place = `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`;
if (compact) {
return (
<>
<p> {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
</>
);
}
return (
<>
<p>{profile.time} · {profile.birthTimeStatusLabel}</p>
<p>{place}</p>
<p> {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
<p> {profile.ascendantSignLabel} · 宿 {profile.moonNakshatra} {profile.moonPada}</p>
<p>{profile.currentDashaLabel} · {profile.engineName}</p>
</>
);
}
@@ -0,0 +1,40 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatCoord } from "./chart-format";
/**
* The chart parameters as one key/value table.
*
* They used to be rendered three times over: a `.chart-page-center-card`
* floating on the geometric centre of the North Indian chart carried two lines
* on mobile and six on desktop, and a `.chart-page-params-below` card repeated
* the same six lines for mobile only. One set of facts, three renders, switched
* by media queries — and every line was a bare sentence with its label fused to
* its value (「岁差 Raman」). A table names each field once and scans down one
* column, so nothing needs duplicating per breakpoint.
*
* Every value here is passed through from the server projection; nothing on
* this page is computed in the browser.
*/
export function ChartParamTable({ view }: { view: ChartViewOk }) {
const { profile } = view;
const rows: ReadonlyArray<readonly [string, string]> = [
["出生时间", `${profile.time} · ${profile.birthTimeStatusLabel}`],
["出生地", `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`],
["岁差", profile.ayanamsaDisplay],
["交点", profile.nodeModeLabel],
["上升", profile.ascendantSignLabel],
["月宿", `${profile.moonNakshatra}${profile.moonPada}`],
["当前大运", profile.currentDashaLabel],
["引擎", profile.engineName],
];
return (
<dl className="chart-page-params-table">
{rows.map(([label, value]) => (
<div className="chart-page-param-row" key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
@@ -27,29 +27,31 @@ export function ChartQizhengTab({ view }: { view: ChartViewOk }) {
<p className="chart-page-note">{qizheng.ketuMode} · {qizheng.coordinateSystem}</p>
<Rules />
<QizhengPalaceGrid qizheng={qizheng} />
<table className="chart-page-planet-table">
<caption>宿</caption>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">宿</th>
<th scope="col">宿</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>宿</caption>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">宿</th>
<th scope="col">宿</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -4,7 +4,7 @@ import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
import { formatDegree } from "./chart-format";
import { ChartParamLines } from "./chart-param-lines";
import { ChartParamTable } from "./chart-param-table";
export function ChartVedicTab({
view,
@@ -45,17 +45,12 @@ export function ChartVedicTab({
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<VedicChartSvg chart={varga.chart} ariaLabel={`${varga.id} 北印度盘 ${varga.meaning}`} />
<div className="chart-page-center-card">
<div className="chart-page-center-compact">
<ChartParamLines view={view} compact />
</div>
<div className="chart-page-center-extra">
<ChartParamLines view={view} />
</div>
</div>
</div>
<div className="chart-page-params-below">
<ChartParamLines view={view} />
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim — it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
<div className="chart-page-planet-table-wrap">
@@ -83,7 +78,6 @@ export function ChartVedicTab({
</div>
</div>
)}
{varga ? <p className="chart-page-boundary chart-page-boundary-desktop">{view.vedic.boundary}</p> : null}
</div>
);
}
@@ -26,24 +26,26 @@ export function ChartWesternTab({ view }: { view: ChartViewOk }) {
</section>
<section>
<h3></h3>
<table className="chart-page-planet-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p> {western.houseSystem === "P" ? "Placidus" : western.houseSystem}</p>
</div>
@@ -126,18 +126,42 @@ export function EphemerisView(props: {
>
<div className="ephemeris-body">
<section className="ephemeris-hero">
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
{/* ` 日期 `: the date is the subject, the two arrows are its handles.
Three equal side-by-side buttons made 前一天 / 今天 / 后一天 read as
three peers and pushed the date itself out of the group entirely.
The arrow glyphs are decorative — the direction words stay as the
accessible names, so nothing is lost to a screen reader. */}
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onPrevious}>
{EPHEMERIS_COPY.previousDay}
<Button
type="button"
variant="outline"
size="icon"
className="ephemeris-date-button"
aria-label={EPHEMERIS_COPY.previousDay}
onClick={props.onPrevious}
>
<span aria-hidden="true"></span>
</Button>
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onToday} disabled={isToday}>
{EPHEMERIS_COPY.today}
</Button>
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onNext}>
{EPHEMERIS_COPY.nextDay}
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
<Button
type="button"
variant="outline"
size="icon"
className="ephemeris-date-button"
aria-label={EPHEMERIS_COPY.nextDay}
onClick={props.onNext}
>
<span aria-hidden="true"></span>
</Button>
</div>
{/* On today there is nowhere to go, so the control is absent rather than
present-but-disabled: a greyed button still asks to be read and
still has to be tabbed past. */}
{isToday ? null : (
<Button type="button" variant="ghost" className="ephemeris-today" onClick={props.onToday}>
{EPHEMERIS_COPY.today}
</Button>
)}
</section>
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
@@ -203,9 +227,10 @@ export function EphemerisView(props: {
)}
</section>
<section className="ephemeris-footer">
<p>{EPHEMERIS_COPY.footerNote}</p>
</section>
{/* The closing note used to be a fourth boxed card competing with the
three that carry data. The sentence is unchanged; it is a quiet line
under the page now, and the 「带这天去提问」 action lives in the header. */}
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
</div>
</SecondaryShell>
);