From 1e3570b46246ecd41576aed31e6e250fd423ac73 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 06:42:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=98=9F=E7=9B=98=20tab=20?= =?UTF-8?q?=E4=B8=8B=E5=88=92=E7=BA=BF=E5=8C=96=E3=80=81=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=94=B6=E6=88=90=E9=94=AE=E5=80=BC=E8=A1=A8=EF=BC=8C=E6=98=9F?= =?UTF-8?q?=E5=8E=86=E6=97=A5=E6=9C=9F=E6=9D=A1=E6=94=B9=20=E2=80=B9=20?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=20=E2=80=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 星盘页(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 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- .../tasks/PROGRESS-cend-chart-eph-20260916.md | 178 ++++++++++++++ frontend/DESIGN.md | 32 ++- frontend/src/app/globals.css | 230 +++++++++++------- .../components/chart-page/chart-page-view.tsx | 9 +- .../chart-page/chart-param-lines.tsx | 31 --- .../chart-page/chart-param-table.tsx | 40 +++ .../chart-page/chart-qizheng-tab.tsx | 46 ++-- .../components/chart-page/chart-vedic-tab.tsx | 18 +- .../chart-page/chart-western-tab.tsx | 36 +-- .../components/ephemeris/ephemeris-page.tsx | 47 +++- frontend/tests/chart-page-view.test.tsx | 67 +++-- frontend/tests/ephemeris-page.test.tsx | 17 +- 12 files changed, 538 insertions(+), 213 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-chart-eph-20260916.md delete mode 100644 frontend/src/components/chart-page/chart-param-lines.tsx create mode 100644 frontend/src/components/chart-page/chart-param-table.tsx diff --git a/docs/tasks/PROGRESS-cend-chart-eph-20260916.md b/docs/tasks/PROGRESS-cend-chart-eph-20260916.md new file mode 100644 index 00000000..0f7a69bd --- /dev/null +++ b/docs/tasks/PROGRESS-cend-chart-eph-20260916.md @@ -0,0 +1,178 @@ +# PROGRESS · 星盘 + 星历 R5 + +任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 R5 +执行方式:**直接执行模式**(subagent) +分支:`codex/cend-chart-eph-20260916` +基线:`origin/staging` = `50ce02c8`(R4 次级页外壳合入后) +BUG 段:不占用(产品改造,未发现真实缺陷) + +--- + +## 开工基线实测 + +在 `50ce02c8` 干净检出、`npm ci` 之后实测: + +| 项 | 基线值 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error / **118 warning** | +| `npm test` | tests **3350** · pass 3304 · fail **31** · skipped 15 | +| `next build` 路由标记 | `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ` | +| 主 CSS chunk | 242,608 字节(gzip **36,745**) | + +31 条失败全部是无 Docker 的既有缺口,清单落在 `/tmp` 基线文件,改后逐条对比。 + +--- + +## T5.1 · 星盘页 + +### 做了什么 + +| 任务书要求 | 做法 | +|---|---| +| 五个体系 tab 改顶栏下方下划线式 tab 行 | `.chart-page-tabs` 从「五枚描边药丸 + 可换行」改成一条 `flex-wrap: nowrap` + 自身横滚的下划线行,整行 1px 底线,当前项 2px 下划线。JSX 里眉标从 tab 行**之上**移到**之下**,让 tab 行直接接住 46px 顶栏 | +| 分盘 chips 保留、视觉降一级 | `.chart-page-chip` 从与 tab 共用的药丸规则里拆出来:去描边、去卡面、caption 字号、三级文字色,当前项只留一块浅底。触摸目标仍 44px | +| 参数收进统一键值表 | 新增 `chart-param-table.tsx`(由 `chart-param-lines.tsx` 改名而来),八行 `dl` 键值表。删除 `.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类及其 767/768 开关块 | +| 边界句放表下方、文案一字不改 | `{view.vedic.boundary}` 直接跟在表后。改前 DOM 里它出现**两次**(一次给手机一次给桌面,靠 CSS 藏一个),现在只渲染一次。**句子本身是服务端字符串,没有被碰过** | +| 行星表加横向滚动容器 | `.chart-page-planet-table-wrap` 加 `min-width: 0`(缺它 grid item 按内容定宽,表格撑宽页面而不是滚动),表加 `min-width: 26rem`。**三张**表全部包上:印度盘宿度、西洋相位、七政十一曜(原来只有第一张有 wrap) | + +眉标 `CHART_VIEW_COPY.eyebrow`(「主盘直接算 · 分盘按需 · 不消耗点数」,BUG-717)保留,只换了位置。 + +按需 layer 拉取(`onNeedLayer("chara"/"western"/"qizheng"/"varga")`)、`CHART_VIEW_COPY.waitingLayer` 等待文案、`waitingChart` / `waitingVarga` 全部未动,没有引入 spinner 或骨架。 + +### 顺带修掉的一处错位 + +`.chart-page-eyebrow` 原本同时吃 `.chart-page-body` 的 `padding-inline` 和 767 块里给它自己的一份,手机上比 tab 行多缩进一个 `--space-4`。眉标移到 tab 行下方后这个错位会直接看得见,所以把 `.chart-page-eyebrow` 从那条共享选择器列表里摘掉了——**只摘这一个选择器**,同列表里 R6 的 `.report-center-body` / `.report-center-message` 一个没动。 + +--- + +## T5.2 · 星历页 + +| 任务书要求 | 做法 | +|---|---| +| 日期导航改居中 `‹ 日期 ›` | 两枚 `size="icon"`(44px)箭头按钮夹住日期;箭头字形 `aria-hidden`,可访问名用 `EPHEMERIS_COPY.previousDay` / `nextDay`,读屏拿到的仍是「前一天」「后一天」 | +| 「今天」非今日时以文字按钮出现在日期下方 | `isToday ? null : - - + {/* 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 : ( + + )}
@@ -203,9 +227,10 @@ export function EphemerisView(props: { )}
-
-

{EPHEMERIS_COPY.footerNote}

-
+ {/* 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. */} +

{EPHEMERIS_COPY.footerNote}

); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index d01b4f5f..07447fd8 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -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 { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " "); + const found = new Set(); + 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(//g)].length; + const wraps = [...source.matchAll(/
/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: "还没有可用来排盘的出生资料。" }, diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx index 122fe02f..b67ff597 100644 --- a/frontend/tests/ephemeris-page.test.tsx +++ b/frontend/tests/ephemeris-page.test.tsx @@ -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日 这一天的天象来看。");