Compare commits

...

3 Commits

Author SHA1 Message Date
Jesse_Chen 5c5f25a33b docs(report): record staging engine smoke and BUG-525 report-worker blocker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 17:31:33 +08:00
Jesse_Chen 7ee7f8258a fix(ui): collapse product weights and radii onto the shared Button
Independent Staging Quality Gate / validate (push) Failing after 9m39s
Independent Staging Quality Gate / publish (push) Has been skipped
Chat body, type weights, and radii were still a second scale; membership purchase now uses the shared Button so the filled terracotta action is one implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 17:29:11 +08:00
Jesse_Chen 7b1354a79b fix(ui): restyle personal report pages to the product reading room
Independent Staging Quality Gate / validate (push) Successful in 8m31s
Independent Staging Quality Gate / publish (push) Successful in 1m58s
The report centre and reader were a second newspaper identity. They now reuse canvas, hairline, and serif display tokens so the document feels like the rest of Jyotisha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 16:30:44 +08:00
16 changed files with 941 additions and 335 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-04 — 产品界面收束到三档字重、两档圆角和共用按钮
聊天正文改为 16px;产品界面字重只保留 400 / 500 / 600。控件圆角 8px、卡片 12px。会员购买、兑换和支付重试改走共用 `Button`。生时校正候选卡间距落到 4px 网格。登录与引导页仍用原来的按钮类名。Skill 版本未变。
## 2026-09-04 — 个人报告中心与阅读页接入产品阅读室视觉
报告页不再用第二套报纸纸色和无衬线加粗标题。中心与阅读页改走产品画布、发丝线、衬线标题(字重 400),terracotta 只留给生成和打印;星盘 SVG 跟主题走,深色模式可读。打印仍钉回浅色纸。Skill 版本未变。
## 2026-09-04 — 生时校正采用卡旁白由 Agent 生成(不计费)
探针池耗尽、出采用卡的那一轮,旁白改为一次无工具模型调用:只根据服务端结构化事实解释为什么停、当前范围与代表分钟、采用后拿什么核对。与采集题意图分类器同一口径,**不计费**。模型失败、超时、或文案里出现事实外的时间/年份时,整段丢弃,回落到带「分不开 A 和 B」的模板句。点选最后一道区分题也走同一条 Agent 路径,不再用点选前的过期决策跳过。Skill 版本仍是 10.0.14;采用门与确认门未改。
+20 -3
View File
@@ -8092,11 +8092,11 @@
- 触发条件:API 出生 payload 的 `hour`/`minute` 为 float`_high_rigor_birth_payload` 的既有口径),且 `birth_time_accuracy``provisional``approximate`,因而会走到 `_birth_datetime_from_args`
- 根因:上游同步把 `_build_birth_time_sensitivity` 无条件接入 consultation_workflow。CLI argparse 的 hour/minute 是 intAPI 路径是 float。`datetime()` 不能接受 float,抛 `TypeError`。异常只被 `except ValueError` 包住,于是冒成 500。
- 修复:`_birth_datetime_from_args` 对年月日时分做 `int()` 规范化,不改变 API float 口径。敏感度构建失败时写入 blocked/`not_available` 状态对象并继续 workflow,不再打死整个端点。
- 验证:`tests/test_consultation_workflow_birth_time_sensitivity.py`(已列入 `CORE_PYTEST_TARGETS`);本地 HTTP 对 career/marriage/wealth/timing/health 与不带敏感度字段的聊天形状均为 200/`success=true`;五份响应喂给 `buildReportEvidenceBundleV2` 得到 5 张 claim card、`blockedSections` 为空。staging quick 门 `585 passed, 1 skipped`
- 验证:`tests/test_consultation_workflow_birth_time_sensitivity.py`(已列入 `CORE_PYTEST_TARGETS`);本地 HTTP 对 career/marriage/wealth/timing/health 与不带敏感度字段的聊天形状均为 200/`success=true`;五份响应喂给 `buildReportEvidenceBundleV2` 得到 5 张 claim card、`blockedSections` 为空。staging quick 门 `585 passed, 1 skipped`staging 上从 web 容器对虚构 smoke 盘 `POST http://api:5200/api/consultation_workflow`provisional + float 时分)HTTP 200、`success=true``birth_time_sensitivity.status=candidate_window_only`,约 74s。产品侧真实 personal_full 仍失败,见 BUG-526,不是本条 TypeError。
- 防复发:float hour/minute 的 API body 必须能走 `execute_consultation_workflow` 且不 500;敏感度层失败必须降级,不得再变成未捕获 `TypeError`。不得把 `_high_rigor_birth_payload` 的 hour/minute 改成 int。
- 相关记录:
- 相关记录:BUG-526
- 复发自:无
- 修复版本:待发布
- 修复版本:`7b1354a79bb8e9b92c3c816fdc01f5f8de2860b7`(含本修复的 staging 部署 SHA
## BUG-525 | 采集题答「没有」后「正在准备下一个问题…」不消失
@@ -8114,3 +8114,20 @@
- 复发自:BUG-491
- 修复版本:待发布
- 编号说明:rebase 到 `origin/staging` 时 BUG-524 已被 consultation_workflow 占用,本条落在 BUG-525。
## BUG-526 | accepted 生时的个人报告 worker 直接读已收回权限的校正表,16 秒 calculation_unavailable
- 状态:investigating
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`frontend/src/lib/personal-report-worker.ts` `createProductionWorker``generate``public.agentic_rectification_cases`
- 用户现象:staging 上 standard personal_full 创建成功后约 16 秒失败,`failureCode = calculation_unavailable`,无章节行。表现与 BUG-524 事故码相同,但引擎访问日志里没有 `POST /api/consultation_workflow`
- 触发条件:资料 `birth_time_status``accepted`(不是 `confirmed`),worker 因此去查 `agentic_rectification_cases``candidate_accepted` 行。
- 根因:`20260814010000_immutable_skill_registry.sql` 已从该表收回 `service_role` 的表级权限,只许走 security definer RPC。报告 worker 仍 `from("agentic_rectification_cases").select(...)`。Postgres 三次 `permission denied for table agentic_rectification_cases`(与 job 三次 attempt、默认 5s/10s 重试对齐)。查询失败被映射成可重试 `calculation_unavailable`,报告从未调用引擎。
- 修复:未做。本轮任务书禁止为 BUG-524 改前端 worker。候选方向:`accepted` 已有可用 `active_birth_time` 时不要读这张锁死表;或经允许的 RPC 取候选窗;查询失败时降级为无 range 继续生成,而不是打死整份报告。
- 验证:staging Postgres 日志三次 permission denied;同期 API 无 consultation_workflow;同机 web→api 虚构 smoke 为 200BUG-524 已修好)。
- 防复发:报告 worker 不得再直接 SELECT 已收回 `service_role` 权限的校正表;`calculation_unavailable` 必须能区分「引擎 500」与「可选候选窗读失败」。
- 相关记录:BUG-524
- 复发自:无
- 修复版本:待修复
- 编号说明:rebase 到 `origin/staging` 时 BUG-525 已被采集拒答占用,本条落在 BUG-526。
@@ -9,8 +9,8 @@
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 1 类型修复 + 降级加固 + 回归测试 | 完成 | BUG-524 |
| 2 staging 部署后真实 personal_full | 待部署 | BUG-524 |
| 3 聊天路径回归确认 | 待部署 | BUG-524 |
| 2 staging 部署后真实 personal_full | 引擎已修好;产品报告被另一处挡住 | BUG-524 / BUG-526 |
| 3 聊天路径回归确认 | 默认模型缺计费配置,未跑到引擎 | BUG-524 |
## 开工复现
@@ -76,3 +76,26 @@ elapsed_ms: 751689
10 passed。
## Staging 真实验证(2026-09-04
`GET https://staging.jyotisha.chat/api/health``.deployment.gitCommit` = `7b1354a79bb8e9b92c3c816fdc01f5f8de2860b7`。运行中 API 已含 `int(args.hour/minute)` 与敏感度降级。未提升 main。下文无账号、姓名、出生资料。
### 引擎(BUG-524)已在部署树上修好
从 web 容器对虚构 smoke 盘 `POST http://api:5200/api/consultation_workflow``birth_time_accuracy=provisional`float 时分):HTTP 200`success=true``birth_time_sensitivity.status=candidate_window_only`,约 74s,响应约 1.2MB。
### 任务 2 · 产品 personal_full 未闭环
登录后点「生成完整报告」。`request_id` `527f95f3-ac2e-4ed7-9b49-3c6ea4be02e8``failed` / `calculation_unavailable`,约 16.5sjob 3/3,进度停在 30%`generating_report`),无 `personal_report_sections` 行。
同期 API 访问日志**没有** `POST /api/consultation_workflow`。Postgres 在 08:59:04 / 08:59:10 / 08:59:20 UTC 三次 `permission denied for table agentic_rectification_cases`SQL 即 worker 对 `candidate_accepted` 的直接 SELECT。`20260814010000_immutable_skill_registry.sql` 已收回该表的 `service_role` 表级权限。资料状态为 `accepted`,worker 因此走进这张锁死表,从未到达已修好的引擎。记为 **BUG-526**(BUG-525 已被采集拒答占用)。
事故行 `85616e32-…`07:38 UTC)同样是约 18s / 3 次 attempt / 无章节;当时 web 容器已 recreate,无法回收那一窗的 Postgres 句。不能把 07:38 也写成 TypeError,只能说与本次 16s 失败形态相同。
任务 2 欠的章节正文、telemetry、`inputTokens` / 2 倍线对照因此仍缺。
### 任务 3 · 聊天未跑到引擎
新开对话点「时运」芯片。产品提示「当前服务的计费配置尚未完成,本次不会扣点」。web 日志 `reason=feature_pricing_missing`。API 无 consultation_workflow。默认聊天模型缺咨询计费配置,与 BUG-524 无关。
+35 -10
View File
@@ -35,15 +35,16 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
| Error | `--color-danger` | `#9a2f2f` | Errors and destructive actions |
| Accessible focus | `--color-focus` | `#85432f` | Keyboard focus and input focus |
The personal report carries a second, narrower palette for its printed-paper
surface. It is deliberately separate — the report is a document, not app chrome —
and nothing outside `.personal-report-*` may use it.
The personal report reuses the product palette. `--report-*` tokens exist so
print can pin a light sheet independently of the screen theme; they are the
canvas, hairline, and action colors, not a second visual identity. Nothing
outside `.personal-report-*` and `.report-center-*` may use them.
| Role | Token | Value | Usage |
|---|---|---:|---|
| Report paper | `--report-paper` | `#f8f5ee` | The report sheet itself |
| Report rule | `--report-rule` | `#c9c2b7` | Rules and dividers inside the sheet |
| Report accent | `--report-accent` | `#85432f` | Report headings, same hue as the action color |
| Report paper | `--report-paper` | `#fbfaf7` | Same as `--color-canvas`; the reading sheet |
| Report rule | `--report-rule` | `#d8d6cf` | Same as `--color-border`; rules inside the sheet |
| Report accent | `--report-accent` | `#85432f` | Same as `--color-action`; scarce evidence links |
Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of the interface remains light. Dark ink is punctuation, never a page-scale surface. No raw color may appear in UI styles outside these tokens and their documented alpha mixes.
@@ -154,7 +155,7 @@ is read through an external store so a change in one tab reaches the others.
| `--type-caption` | `13px` | 500 | 1.4 | 0 | Labels and metadata |
| `--type-overline` | `12px` | 500 | 1.4 | `1.5px` | Eyebrows and badges |
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 1213px is reserved for short labels and metadata. CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 1213px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
@@ -162,6 +163,8 @@ One documented exception: the thinking text inside a timeline step (`.consultati
The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: 12px`, `--space-4: 16px`, `--space-5: 20px`, `--space-6: 24px`, `--space-8: 32px`, `--space-10: 40px`, `--space-12: 48px`, `--space-16: 64px`, and `--space-24: 96px`.
Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and `--radius-sm` alias that value). Cards and sheets use 12px (`--radius-lg`; `--radius-xl` aliases it). Circles stay `50%`; pills stay `999px`.
- Chat reading width: 760px for the welcome/composer and 900px for long answers. Both chat surfaces share the 900px transcript width; the rectification session no longer narrows it to 720px.
- Admin content width: 1200px, centered.
- Desktop shell: 288px sidebar plus flexible reading panel.
@@ -180,6 +183,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
### Button
- **Implementation:** report surfaces and membership use `@/components/ui/button`. Login, onboarding, and birth-time dialogs still use `.button-primary` / `.button-secondary`, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growing `page.tsx`.
- **Variants:** ink primary, cream secondary, text, circular icon, deep-brown emphasis.
- **Spacing:** 44px minimum height; radii 8px for standard and full radius for icon-only.
- **States:** default, hover, active, focus-visible, disabled, loading.
@@ -307,7 +311,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. Streaming uses a timeline of completed steps plus the current step; the thinking body expands while streaming, collapses when answer text appears, and is stored with the assistant message.
- **Identity:** every assistant message carries the 32px Jyotisha logo avatar; user messages stay visually lighter and avatar-free.
- **Typography:** assistant body 17px (16px below 768px) with serif subheadings; user body 14px.
- **Typography:** assistant body `--type-body-md` (16px) with serif subheadings; user body 14px.
- **Tables:** three-column technique audit tables keep 状态 on one line. Below 768px they stack each row as title + status, then the note, instead of squeezing 已执行 into a vertical glyph column.
- **Follow-up:** the latest settled consultation answer may offer two or three grounded next questions under that answer. Clicking one sends it in the current session. The composer never hosts suggestion chips. If the answer does not support a grounded continuation, nothing is shown.
- **Motion:** a new row enters once, through the GSAP tween in `chat-message-row.tsx` at the 160ms Message duration; there is no CSS entrance keyframe beside it. The trailing assistant reply is one component (`LatestAssistantEntry`) from its first streamed token through settlement, so settling never remounts it and never replays the entrance.
@@ -337,8 +341,24 @@ Text release is paced, not animated: the frame buffer commits at most once per a
### Personal report centre
- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
- **Structure:** full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards.
- **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block.
- **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`.
- **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px.
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
### Personal report reader
- **Structure:** sticky screen chrome (back, print), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance.
- **Surface:** page floor `--color-canvas-soft`; the document is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome, and pins the light palette.
- **Typography:** the subject name uses `--type-display-lg` serif at weight 400. Section titles use `--type-display-sm` serif at weight 400. Theme headings are `--type-title-md` sans at weight 500. Narrative is `--type-body-md` at 1.65, the same measure as chat answers. Labels stay 1213px.
- **Accent:** claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Charts:** North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex.
- **Width:** 900px for the document; chart and evidence columns stack at 760px.
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses `InlineSpinner`.
- **Accessibility:** back, print, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button.
### Product entrypoint card
@@ -373,6 +393,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a
- **Width:** 420px desktop maximum.
- **States:** open, submitting, success, and error.
### Membership
- **Structure:** full-page archive on the page floor. Header chrome is back, orders, and redeem; the hero names the current plan; tabs switch membership vs credits; each plan/credit card carries one purchase action.
- **Actions:** header chrome stays hairline `--color-ink-secondary`. The one filled terracotta control is the recommended-plan purchase (`Button` default). Other plan and credit buys use `Button` outline. Redeem opens a dialog; it is not a second filled button on the same screen.
### Logout dialog
- **Structure:** confirmation title and explanation, cancel action, and destructive confirm action.
File diff suppressed because it is too large Load Diff
+19 -16
View File
@@ -6,6 +6,7 @@ import type { FormEvent } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
@@ -348,14 +349,14 @@ function MembershipContent() {
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<button
className="button-secondary"
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingProductId === selectedProductId ? "创建中…" : "重新支付"}
</button>
</Button>
)}
</div>
)}
@@ -406,14 +407,15 @@ function MembershipContent() {
<li></li>
</ul>
</details>
<button
className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
<Button
className="membership-buy"
variant={recommended ? "default" : "outline"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买"}
</button>
</Button>
</article>
);
})}
@@ -444,14 +446,15 @@ function MembershipContent() {
<li></li>
</ul>
</details>
<button
className="button-secondary membership-buy"
<Button
className="membership-buy"
variant="outline"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : "立即购买"}
</button>
</Button>
</article>
))}
</div>
@@ -491,16 +494,16 @@ function MembershipContent() {
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<button
className="button-secondary"
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingProductId === paymentOrder.productId ? "创建中…" : "重新支付"}
</button>
</Button>
)}
<button className="button-primary" type="button" onClick={goBack}></button>
<Button type="button" onClick={goBack}></Button>
</div>
</section>
)}
@@ -544,15 +547,15 @@ function MembershipContent() {
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
<Button type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</button>
</Button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<button className="button-primary" type="button" onClick={closeRedeem}></button>
<Button type="button" onClick={closeRedeem}></Button>
</div>
)}
</form>
@@ -20,12 +20,10 @@ export default function ReportError({
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<h1></h1>
<p></p>
<Button type="button" variant="outline" onClick={() => unstable_retry()}>
</Button>
@@ -3,11 +3,9 @@ import "../../site-styles";
export default function ReportLoading() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p className="text-ink" role="status">
</p>
<p role="status"></p>
</main>
);
}
@@ -7,11 +7,9 @@ import "../../site-styles";
export default function ReportNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<main className="personal-report-state">
<h1></h1>
<p></p>
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
</Button>
@@ -20,6 +20,8 @@ import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing", "health"] as const;
export interface PersonalReportCreateRequest {
@@ -190,15 +192,15 @@ export function GeneratePersonalReportButton({
}
return (
<button
<Button
type="button"
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50"
className="shrink-0 whitespace-nowrap"
onClick={() => void handleGenerate()}
disabled={submitting}
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
>
<FileText aria-hidden="true" className="size-4" />
<FileText aria-hidden="true" />
{submitting ? "正在创建…" : "生成完整报告"}
</button>
</Button>
);
}
@@ -221,7 +221,7 @@ export function PersonalReportCenter() {
return (
<article className="report-center-card" key={report.id}>
<div className="report-center-card-body">
<div className={`report-center-status is-${report.status}`}>
<div className={`report-center-status is-${report.status}`} role={report.status === "generating" ? "status" : undefined}>
<StatusIcon status={report.status} />{copy.label}
</div>
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
@@ -229,7 +229,7 @@ export function PersonalReportCenter() {
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<div className="flex flex-wrap items-center justify-end gap-2 max-[720px]:justify-start">
<div className="report-center-card-actions">
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
</Button>
@@ -243,7 +243,7 @@ export function PersonalReportCenter() {
</Button>
{exportError?.reportId === report.id ? (
<span className="basis-full text-right text-sm text-destructive max-[720px]:text-left" role="alert">
<span className="report-center-export-error" role="alert">
{exportError.message}
</span>
) : null}
@@ -197,6 +197,7 @@ function ReportCover({ document, d1 }: { document: ReportDocument; d1: ChartV1 |
<header className="personal-report-cover personal-report-section">
<div className="personal-report-cover-grid">
<div>
<p className="personal-report-kicker"></p>
<h1>{document.subject.displayName}</h1>
<p className="personal-report-deck">{document.executiveSummary.headline}</p>
</div>
@@ -229,17 +229,15 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "loading" || state.phase === "generating") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p className="text-ink" role="status">
<p role="status">
{generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"}
</p>
{generating && (
<>
<p className="text-sm text-ink-tertiary"> {formatWaitedDuration(waitedMs)}</p>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<p> {formatWaitedDuration(waitedMs)}</p>
<p></p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="ghost"></Button>
</>
)}
@@ -249,13 +247,11 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "timed-out") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="text-sm text-ink-tertiary"> {formatWaitedDuration(waitedMs)}</p>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<h1></h1>
<p> {formatWaitedDuration(waitedMs)}</p>
<p></p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button type="button" variant="default" onClick={() => keepWaiting()}>
@@ -270,11 +266,9 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "unauthorized") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<main className="personal-report-state">
<h1></h1>
<p></p>
<Button render={<Link href="/login" />} nativeButton={false} variant="default">
</Button>
@@ -284,11 +278,9 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "not-found") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<main className="personal-report-state">
<h1></h1>
<p></p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
</Button>
@@ -298,14 +290,12 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "failed") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}
</p>
<h1></h1>
<p>{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}</p>
{state.failureCode && (
<p className="font-mono text-xs text-ink-tertiary">{state.failureCode}</p>
<p>{state.failureCode}</p>
)}
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
@@ -316,10 +306,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "invalid" || state.phase === "network-error") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<main className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
<h1></h1>
<p>
{state.phase === "invalid"
? `报告数据未通过校验(${state.message}),已停止渲染。`
: "网络连接失败,请检查网络后重试。"}
@@ -91,7 +91,6 @@ function PlanetList({ lines }: { lines: string[] }) {
x={6 + column * 49}
y={45 + row * rowStep}
fontSize={fontSize}
fill="#1d1d1f"
>
{truncateSvgLabel(line, maxCharacters)}
</text>
@@ -141,15 +140,13 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
y={cell.y}
width={CELL}
height={CELL}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-cell"
/>
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} fill="#6a6963">
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} className="personal-report-chart-muted">
{houseNumber}
</text>
{house && (
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600} fill="#32322f">
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600}>
{house.sign}
</text>
)}
@@ -164,18 +161,14 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
y={CELL}
width={CELL * 2}
height={CELL * 2}
fill="#f3f2ee"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-core"
/>
<path
d={`M ${CELL * 2} ${CELL} L ${CELL * 3} ${CELL * 2} L ${CELL * 2} ${CELL * 3} L ${CELL} ${CELL * 2} Z`}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
className="personal-report-chart-diamond"
/>
{hasRetrogradeMarker && (
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} fill="#6a6963">
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} className="personal-report-chart-muted">
=
</text>
)}
+10 -2
View File
@@ -162,7 +162,12 @@ test("plan cards carry price, duration, audience, entitlements, purchase and rul
assert.match(pageSource, /entitlementLabel\(entitlement\)/);
assert.match(pageSource, /<details className="membership-rules">/);
assert.match(pageSource, /<summary>详细规则<\/summary>/);
assert.match(pageSource, /className=\{recommended \? "button-primary membership-buy" : "button-secondary membership-buy"\}/);
// 原值: className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
// 新值: Button className="membership-buy" + variant default|outline
// 原因: 会员购买走共用 Button;登录/引导仍用 .button-primary 类(page.tsx 冻结与破坏确认合同)
assert.match(pageSource, /import \{ Button \} from "@\/components\/ui\/button"/);
assert.match(pageSource, /className="membership-buy"/);
assert.match(pageSource, /variant=\{recommended \? "default" : "outline"\}/);
});
test("renewal semantics map the active subscription to the matching plan", () => {
@@ -305,7 +310,10 @@ test("redeem modal reuses the existing account dialog classes", () => {
assert.match(pageSource, /className="redeem-form"/);
assert.match(pageSource, /className="form-error" role="alert"/);
assert.match(pageSource, /className="form-success" role="status"/);
assert.match(pageSource, /className="button-primary"/);
// 原值: className="button-primary"
// 新值: <Button type="submit">
// 原因: 兑换提交改走共用 Button,不再复用登录页的 .button-primary 类名
assert.match(pageSource, /<Button type="submit"/);
});
test("desktop uses three columns, tablet two and mobile one with monthly first", () => {
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const luminance = (hex: string) => {
@@ -66,5 +68,22 @@ test("print pins the palette back to light so paper is always paper", () => {
assert.ok(printRoot.has(token), `@media print must reset --${token}`);
}
assert.equal(printRoot.get("color-ink"), "#1d1d1f");
assert.equal(printRoot.get("report-paper"), "#f8f5ee");
assert.equal(printRoot.get("report-paper"), "#fbfaf7");
assert.equal(printRoot.get("report-rule"), "#d8d6cf");
});
test("report titles use the product serif display stack at weight 400", () => {
const centreTitle = cssDeclarations(".report-center-hero h1");
const coverTitle = cssDeclarations(".personal-report-cover h1");
const sectionTitle = cssDeclarations(".personal-report-section-heading h2");
for (const [name, body] of [["centre", centreTitle], ["cover", coverTitle], ["section", sectionTitle]] as const) {
assert.match(body, /font-family:\s*var\(--font-display\)/, `${name} title must use --font-display`);
assert.match(body, /font-weight:\s*400/, `${name} title must stay weight 400`);
}
});
test("report chrome and appendix disclosure keep 44px targets", () => {
assert.match(cssDeclarations(".personal-report-back"), /min-height:\s*44px/);
assert.match(cssDeclarations(".personal-report-disclosure"), /min-height:\s*44px/);
assert.match(cssDeclarations(".report-center-back"), /min-height:\s*44px/);
});