feat(ui): 星盘/星历/报告中心并入 app 外壳,侧栏不再消失
三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的 *-shell / *-topbar(只有一个「返回对话」链接)/ *-hero 骨架, 侧栏在打开它们的瞬间整个消失。 新增 AppNavRail(只读:两个 GET,零写操作)与 SecondaryShell。 会话行走 sessionHref → /?c=<uuid>,跳转复用现有侧栏的 persistLoginSessionReturn + location.assign,行为完全一致。 刻意不带重命名/收藏/归档/删除与账户菜单:那套连着 Home() 的乐观更新与 回滚层,为四个路由把它整体上提远超需要,且会撞 useState 增长门禁。 四个路由渲染标记完全不变:/ ○、/chart ○、/ephemeris ○、/reports ƒ。 CSS gzip 39,825→39,727(−0.25%);per-route JS 体积构建不输出, 已作为口径缺口记录。 两处自身问题被测试抓到并修复:usePathname() 在 app-router 上下文外 返回 null(类型说是 string)、fetch 在 jsdom 里可能不存在。 另差点随 hero 一起丢掉 BUG-717 的 eyebrow 文案(成本与速度承诺, 会印在失败页上),已放回 tab 行下方。 /reports/[reportId] 留给 R6 与目录一起做;根边界页保留「返回对话」, 它们不得 import globals.css,挂不了外壳。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Opus 5
parent
b3ea8c336e
commit
50ce02c837
+29
-2
@@ -373,12 +373,39 @@ Every assistant reply moves through the same states on both chat surfaces, and e
|
||||
|
||||
Text release is paced, not animated: the frame buffer commits at most once per animation frame and reveals `max(2, ⌈backlog ÷ 12⌉)` characters per frame, so a burst catches up in about twelve frames and a slow model never reads as stalled. The reader's own toggle on the timeline always wins over the state default. Under `prefers-reduced-motion: reduce` the collapse and the label fade are instant; the pacing stays, because it is content arrival rather than decoration.
|
||||
|
||||
### Secondary page shell
|
||||
|
||||
`/chart`, `/ephemeris` and `/reports` share the chat shell through
|
||||
`SecondaryShell`. Each used to own a full-screen layout — a `*-shell` root, a
|
||||
`*-topbar` with one 「返回对话」 link, and a `*-hero` with a page-sized h1 —
|
||||
three copies of one skeleton, none of them carrying the sidebar.
|
||||
|
||||
- **Composition:** `SidebarProvider` → `AppNavRail` → `SidebarInset.chat-panel.secondary-panel`.
|
||||
The header is the same 46px row the chat page uses: sidebar trigger, page name,
|
||||
an optional quiet note chip, and up to two right-aligned actions. The body is
|
||||
the only thing that scrolls, and it owns the page floor (`--color-canvas-soft`).
|
||||
- **The rail is read-only.** It carries the brand, 新建对话, the three page
|
||||
actions, and one flat 「最近」 list whose rows navigate to `/?c=<uuid>`.
|
||||
Renaming, pinning, archiving, deleting and the account menu stay on `/`: they
|
||||
are backed by `Home()`'s optimistic-update and rollback layer, and lifting that
|
||||
so four routes can share it is far more than these pages need. The footer shows
|
||||
identity and balance as text, not as a menu trigger.
|
||||
- **Loading:** the rail renders its nav immediately and fills the list when
|
||||
`/api/sessions` returns. While that is in flight the list area carries static
|
||||
copy — never a skeleton, per the unified-loading ruling.
|
||||
- **Signed out:** the rail degrades to brand, nav, a 「登录后可以看到你的对话」
|
||||
line, and a 去登录 footer. It never renders an error state of its own.
|
||||
- **Page name:** lives in the header, so none of these pages carries an `h1` any
|
||||
more. A page's own reassurance copy (the chart page's cost-and-speed eyebrow,
|
||||
for one) stays in the body; it is content, not chrome.
|
||||
|
||||
### Personal report centre
|
||||
|
||||
- **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.
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits in the 46px header with 「生成完整报告」 as the header's one action; the body is supporting copy, the overview line, then the list of report cards.
|
||||
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
|
||||
- **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.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Cards stack at the global 767px cut.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and “导出报告(.md)” beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **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 starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
|
||||
|
||||
+110
-186
@@ -1973,6 +1973,104 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Secondary pages inside the app shell ─────────────────────────────────
|
||||
/chart, /ephemeris and /reports used to be standalone full-screen routes,
|
||||
each with its own `*-shell` root, a `*-topbar` holding one 「返回对话」 link,
|
||||
and a `*-hero` with a page-sized h1. Three copies of one skeleton, none of
|
||||
them carrying the sidebar. They share the chat panel now; the page name lives
|
||||
in the 46px header and the body is the only thing that scrolls. */
|
||||
.secondary-panel { background: var(--color-canvas-soft); }
|
||||
.secondary-panel > :not(.chat-header) {
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chart-page-eyebrow {
|
||||
width: min(1100px, 100%);
|
||||
margin: var(--space-4) auto 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.chart-page-body,
|
||||
.ephemeris-body,
|
||||
.report-center-body,
|
||||
.chart-page-message,
|
||||
.ephemeris-message,
|
||||
.report-center-message {
|
||||
padding: 0 var(--space-6) var(--space-16);
|
||||
}
|
||||
.chart-page-message,
|
||||
.ephemeris-message,
|
||||
.report-center-message {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 60%;
|
||||
text-align: center;
|
||||
}
|
||||
.chart-page-message p,
|
||||
.ephemeris-message p,
|
||||
.report-center-message p {
|
||||
max-width: 44ch;
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.chart-page-message h2,
|
||||
.ephemeris-message h2,
|
||||
.report-center-message h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* The rail is the chat sidebar minus every write action. It reuses the sidebar
|
||||
surface and row styles; only the identity block differs, because there is no
|
||||
account menu to open from here. */
|
||||
.nav-rail-row { width: 100%; }
|
||||
.nav-rail-identity {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--sidebar-foreground);
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-rail-identity > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-rail-identity > small {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
button.nav-rail-identity { cursor: pointer; }
|
||||
button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Replaces the three `*-shell { padding-inline }` rules the standalone pages
|
||||
each carried. Same global 767px cut, one rule instead of three. */
|
||||
.chart-page-body,
|
||||
.ephemeris-body,
|
||||
.report-center-body,
|
||||
.chart-page-message,
|
||||
.ephemeris-message,
|
||||
.report-center-message,
|
||||
.chart-page-eyebrow { padding-inline: var(--space-4); }
|
||||
}
|
||||
|
||||
/* Starter home: greeting, composer, entry pills */
|
||||
.welcome {
|
||||
width: min(var(--session-column-width), 100%);
|
||||
@@ -3278,60 +3376,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
|
||||
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* Global report centre — quiet archive, independent from chat sessions. */
|
||||
.report-center-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 var(--space-6) var(--space-16);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.report-center-topbar,
|
||||
.report-center-hero,
|
||||
.report-center-section {
|
||||
/* Global report centre — quiet archive, independent from chat sessions. */.report-center-hero,
|
||||
.report-center-section{
|
||||
width: min(900px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.report-center-topbar {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
}
|
||||
.report-center-back {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.report-center-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
|
||||
.report-center-back:focus-visible,
|
||||
.report-center-section-heading button:focus-visible,
|
||||
}.report-center-section-heading button:focus-visible,
|
||||
.personal-report-back:focus-visible,
|
||||
.personal-report-disclosure:focus-visible {
|
||||
.personal-report-disclosure:focus-visible{
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.report-center-back svg,
|
||||
.report-center-section-heading button svg,
|
||||
}.report-center-section-heading button svg,
|
||||
.personal-report-back svg,
|
||||
.personal-report-action-end svg { width: 16px; height: 16px; }
|
||||
.personal-report-action-end svg{ width: 16px; height: 16px; }
|
||||
.report-center-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -3340,16 +3396,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: var(--space-10) 0 var(--space-8);
|
||||
}
|
||||
.report-center-hero > div { max-width: 760px; }
|
||||
.report-center-hero h1 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-lg);
|
||||
font-weight: 500;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.report-center-hero > div > p:not(.report-center-meta) {
|
||||
max-width: 660px;
|
||||
margin: var(--space-4) 0 0;
|
||||
@@ -3511,10 +3557,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.report-center-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.report-center-shell { padding-inline: var(--space-4); }
|
||||
.report-center-hero { grid-template-columns: 1fr; align-items: start; gap: var(--space-6); padding-block: var(--space-8) var(--space-6); }
|
||||
.report-center-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; }
|
||||
.report-center-hero > button { width: 100%; }
|
||||
.report-center-card { grid-template-columns: 1fr; gap: var(--space-4); }
|
||||
.report-center-card-actions,
|
||||
.report-center-card > a,
|
||||
@@ -4314,84 +4357,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
|
||||
.rectification-message-wrap { display: grid; gap: var(--space-1); }
|
||||
|
||||
/* Read-only natal chart page. Independent route; does not grow page.tsx. */
|
||||
.chart-page-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 var(--space-6) var(--space-16);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.chart-page-topbar,
|
||||
.chart-page-hero,
|
||||
.chart-page-tabs,
|
||||
.chart-page-panel {
|
||||
/* Read-only natal chart page. Independent route; does not grow page.tsx. */.chart-page-tabs,
|
||||
.chart-page-panel{
|
||||
width: min(1100px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.chart-page-topbar {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-page-back {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chart-page-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
|
||||
.chart-page-back:focus-visible,
|
||||
.chart-page-tab:focus-visible,
|
||||
.chart-page-chip:focus-visible {
|
||||
}.chart-page-tab:focus-visible,
|
||||
.chart-page-chip:focus-visible{
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chart-page-back svg { width: 16px; height: 16px; }
|
||||
.chart-page-hero { padding: var(--space-8) 0 var(--space-6); }
|
||||
.chart-page-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
.chart-page-hero h1 {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-lg);
|
||||
font-weight: 500;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.chart-page-hero > p:not(.chart-page-eyebrow):not(.chart-page-birthline) {
|
||||
max-width: 40em;
|
||||
margin: var(--space-4) 0 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-md);
|
||||
line-height: 1.65;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.chart-page-birthline {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.chart-page-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -4603,7 +4577,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.chart-page-hero h1 { font-size: var(--type-display-md); }
|
||||
.chart-page-center-compact { display: block; }
|
||||
.chart-page-center-extra { display: none; }
|
||||
.chart-page-params-below { display: block; }
|
||||
@@ -4640,64 +4613,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
|
||||
/* ============================================================
|
||||
Ephemeris page — facts only, own scroll (html/body overflow hidden)
|
||||
============================================================ */
|
||||
.ephemeris-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 var(--space-6) var(--space-16);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.ephemeris-topbar,
|
||||
.ephemeris-hero,
|
||||
============================================================ */.ephemeris-hero,
|
||||
.ephemeris-section,
|
||||
.ephemeris-footer {
|
||||
.ephemeris-footer{
|
||||
width: min(900px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.ephemeris-topbar {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ephemeris-back {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ephemeris-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
|
||||
.ephemeris-back:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ephemeris-hero {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) 0 var(--space-8);
|
||||
}
|
||||
.ephemeris-hero h1 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-lg);
|
||||
font-weight: 500;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.ephemeris-date-label {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
@@ -4855,8 +4781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.ephemeris-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ephemeris-shell { padding-inline: var(--space-4); }
|
||||
.ephemeris-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; }
|
||||
.ephemeris-date-bar { display: grid; grid-template-columns: 1fr 1fr 1fr; }
|
||||
.ephemeris-date-button { width: 100%; }
|
||||
.ephemeris-panchanga {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
|
||||
import { groupSessionsByRecency } from "@/lib/session-groups";
|
||||
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
|
||||
|
||||
/**
|
||||
* The nav that /chart, /ephemeris and /reports carry.
|
||||
*
|
||||
* Those three pages used to be standalone full-screen routes whose only way back
|
||||
* was a 「返回对话」 link: the sidebar disappeared entirely the moment you opened
|
||||
* a chart. This rail keeps it on screen, at the cost of being read-only —
|
||||
* renaming, pinning, archiving and deleting stay on `/`, where the state that
|
||||
* backs them lives. See TASK-cend-surfaces-claude-alignment-20260916 D9.
|
||||
*/
|
||||
|
||||
const NAV_PAGES = [
|
||||
{ href: "/chart", label: "星盘", icon: Orbit },
|
||||
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
|
||||
{ href: "/reports", label: "我的报告", icon: FileText },
|
||||
] as const;
|
||||
|
||||
export function AppNavRail() {
|
||||
const { sessions, account, settled, signedOut } = useNavRail();
|
||||
const { isMobile, setOpenMobile, state } = useSidebar();
|
||||
/* Null outside an app-router context (and in unit tests that mount this
|
||||
component directly), even though the type says string. */
|
||||
const pathname = usePathname() ?? "";
|
||||
const showExpandedContent = !(state === "collapsed" && !isMobile);
|
||||
|
||||
const pinned = sessions.filter((session) => session.pinned);
|
||||
const history = sessions.filter((session) => !session.pinned);
|
||||
const groups = groupSessionsByRecency(history);
|
||||
|
||||
/* Same navigation the chat sidebar performs when it leaves for one of these
|
||||
pages: a real document load, with the return target stored first. */
|
||||
function go(href: string) {
|
||||
persistLoginSessionReturn();
|
||||
if (isMobile) setOpenMobile(false);
|
||||
window.location.assign(href);
|
||||
}
|
||||
|
||||
function renderRow(session: NavRailSession) {
|
||||
return (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<button
|
||||
className="session-row nav-rail-row"
|
||||
type="button"
|
||||
onClick={() => go(sessionHref("", session.id))}
|
||||
>
|
||||
<span className="session-title">{session.title}</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="sidebar" aria-label="对话导航">
|
||||
<SidebarHeader className="sidebar-header">
|
||||
<div className="brand-row">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
{showExpandedContent ? <strong>Jyotisha</strong> : null}
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="new-chat" type="button" tooltip="新对话" onClick={() => go("/")}>
|
||||
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>新建对话</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
<SidebarMenuButton
|
||||
className="report-nav-button"
|
||||
type="button"
|
||||
tooltip={label}
|
||||
isActive={pathname === href || pathname.startsWith(`${href}/`)}
|
||||
onClick={() => go(href)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{label}</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{showExpandedContent ? (
|
||||
<SidebarGroup className="session-nav" aria-label="最近对话">
|
||||
<p className="sidebar-section-label">最近</p>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{/* Static copy while the list is in flight — never a skeleton. */}
|
||||
{sessions.length === 0 ? (
|
||||
<p className="sidebar-empty">
|
||||
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
|
||||
</p>
|
||||
) : (
|
||||
<SidebarMenu className="session-list">
|
||||
{pinned.length > 0 ? (
|
||||
<div>
|
||||
<p className="sidebar-group-label">
|
||||
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
|
||||
收藏
|
||||
</p>
|
||||
{pinned.map(renderRow)}
|
||||
</div>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<p className="sidebar-group-label">{group.label}</p>
|
||||
{group.sessions.map(renderRow)}
|
||||
</div>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="sidebar-footer">
|
||||
{signedOut ? (
|
||||
<button className="nav-rail-identity" type="button" onClick={() => go("/login")}>
|
||||
<span className="profile-initial" aria-hidden="true">·</span>
|
||||
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
||||
</button>
|
||||
) : (
|
||||
/* Identity only. Account actions live in the chat page's own menu; a
|
||||
second copy here would need the settings dialog stack to come with it. */
|
||||
<div className="nav-rail-identity" aria-label={account ? `${account.name},余额 ${account.credits} 点` : undefined}>
|
||||
<span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>
|
||||
{showExpandedContent && account ? (
|
||||
<>
|
||||
<span><b>{account.name}</b></span>
|
||||
<small>{account.credits} 点</small>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { useChartPage } from "@/hooks/use-chart-page";
|
||||
import {
|
||||
CHART_VIEW_TABS,
|
||||
@@ -59,34 +58,26 @@ export function ChartPageView({
|
||||
if (id !== "D1") onNeedLayer?.("varga");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="chart-page-shell">
|
||||
<header className="chart-page-topbar">
|
||||
<Link href="/" className="chart-page-back">
|
||||
<ArrowLeft aria-hidden="true" />
|
||||
返回对话
|
||||
</Link>
|
||||
</header>
|
||||
const birthline = view != null && view.status === "ok"
|
||||
? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SecondaryShell title="星盘" note={birthline}>
|
||||
{view == null ? (
|
||||
<section className="chart-page-hero">
|
||||
<h1>星盘</h1>
|
||||
<section className="chart-page-message">
|
||||
<p>{CHART_VIEW_COPY.waitingChart}</p>
|
||||
</section>
|
||||
) : view.status !== "ok" ? (
|
||||
<section className="chart-page-hero">
|
||||
<h1>星盘</h1>
|
||||
<section className="chart-page-message">
|
||||
<p>{view.message}</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="chart-page-hero">
|
||||
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
|
||||
<h1>星盘</h1>
|
||||
<p className="chart-page-birthline">
|
||||
{view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel}
|
||||
</p>
|
||||
</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
|
||||
@@ -128,8 +119,8 @@ export function ChartPageView({
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { setComposerDraft } from "@/lib/composer-draft";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
import {
|
||||
@@ -58,11 +59,13 @@ export function EphemerisPage() {
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<main className="ephemeris-shell ephemeris-message">
|
||||
<h1>{EPHEMERIS_COPY.loginTitle}</h1>
|
||||
<p>{EPHEMERIS_COPY.loginBody}</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
|
||||
</main>
|
||||
<SecondaryShell title={EPHEMERIS_COPY.title}>
|
||||
<section className="ephemeris-message">
|
||||
<h2>{EPHEMERIS_COPY.loginTitle}</h2>
|
||||
<p>{EPHEMERIS_COPY.loginBody}</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
|
||||
</section>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,13 +116,16 @@ export function EphemerisView(props: {
|
||||
const isToday = Boolean(props.date) && props.date === props.today;
|
||||
|
||||
return (
|
||||
<main className="ephemeris-shell">
|
||||
<header className="ephemeris-topbar">
|
||||
<Link className="ephemeris-back" href="/">{EPHEMERIS_COPY.back}</Link>
|
||||
</header>
|
||||
|
||||
<SecondaryShell
|
||||
title={EPHEMERIS_COPY.title}
|
||||
actions={(
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div className="ephemeris-body">
|
||||
<section className="ephemeris-hero">
|
||||
<h1>{EPHEMERIS_COPY.title}</h1>
|
||||
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
|
||||
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
|
||||
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onPrevious}>
|
||||
@@ -199,10 +205,8 @@ export function EphemerisView(props: {
|
||||
|
||||
<section className="ephemeris-footer">
|
||||
<p>{EPHEMERIS_COPY.footerNote}</p>
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
@@ -166,30 +167,30 @@ export function PersonalReportCenter() {
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<main className="report-center-shell report-center-message">
|
||||
<FileText aria-hidden="true" className="size-8 text-primary" />
|
||||
<h1>请先登录</h1>
|
||||
<p>登录后即可生成和查看你的个人报告。</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>去登录</Button>
|
||||
</main>
|
||||
<SecondaryShell title="我的报告">
|
||||
<section className="report-center-message">
|
||||
<FileText aria-hidden="true" className="size-8 text-primary" />
|
||||
<h2>请先登录</h2>
|
||||
<p>登录后即可生成和查看你的个人报告。</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>去登录</Button>
|
||||
</section>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="report-center-shell">
|
||||
<header className="report-center-topbar">
|
||||
<Link href="/" className="report-center-back"><ArrowLeft aria-hidden="true" />返回对话</Link>
|
||||
</header>
|
||||
|
||||
<SecondaryShell
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
>
|
||||
<div className="report-center-body">
|
||||
<section className="report-center-hero">
|
||||
<div>
|
||||
<h1>个人报告</h1>
|
||||
<p>有填报到分钟的出生时间即可生成;未校正会标明为方向性参考。只有日期和时段时,完整本命报告需要具体分钟,生时校正可选。与某一次对话无关。创建后会在后台继续处理,你无需停留等待。</p>
|
||||
<p className="report-center-meta" aria-label="报告概览">
|
||||
共 {state.reports.length} 份 · 已完成 {state.reports.filter((report) => report.status === "ready").length} 份 · {hasGenerating ? "有报告生成中" : latestReady ? "最新报告可查看" : "尚待生成"}
|
||||
</p>
|
||||
</div>
|
||||
<GeneratePersonalReportButton onCreated={() => void load()} />
|
||||
</section>
|
||||
|
||||
<section className="report-center-section">
|
||||
@@ -261,6 +262,7 @@ export function PersonalReportCenter() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AppNavRail } from "@/components/app-nav-rail";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
/**
|
||||
* The app shell for /chart, /ephemeris and /reports.
|
||||
*
|
||||
* Each of those pages used to own a full-screen layout of its own — a
|
||||
* `*-shell` root, a `*-topbar` holding one 「返回对话」 link, and a `*-hero` with
|
||||
* a page-sized h1. Three copies of the same skeleton, and none of them had the
|
||||
* sidebar, so opening a chart dropped the reader out of the app entirely.
|
||||
*
|
||||
* The page name now sits in the 46px header beside the sidebar trigger, which is
|
||||
* where the chat page keeps its session title.
|
||||
*/
|
||||
|
||||
export type SecondaryShellProps = {
|
||||
/** Shown in the header. Pass a falsy value for pages that carry their own. */
|
||||
readonly title: string;
|
||||
/** Right-aligned header controls: the one primary action, at most two. */
|
||||
readonly actions?: ReactNode;
|
||||
/** A quiet chip beside the title — birth line, report date. */
|
||||
readonly note?: ReactNode;
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export function SecondaryShell({ title, actions, note, children }: SecondaryShellProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<main className="chat-app">
|
||||
<AppNavRail />
|
||||
<SidebarInset className="chat-panel secondary-panel">
|
||||
<header className="chat-header has-title">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<div className="chat-header-title">
|
||||
<strong>{title}</strong>
|
||||
{note ? <span className="chat-header-chart">{note}</span> : null}
|
||||
</div>
|
||||
{actions ? <div className="chat-header-actions">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
|
||||
*
|
||||
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
|
||||
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
|
||||
* rollback included. Lifting that whole layer so four routes can share it is far
|
||||
* more than these pages need: none of them offers session management. Two GETs
|
||||
* are enough, and this hook never writes.
|
||||
*/
|
||||
|
||||
export type NavRailSession = {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly pinned: boolean;
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
export type NavRailAccount = {
|
||||
readonly name: string;
|
||||
readonly initial: string;
|
||||
readonly credits: number;
|
||||
};
|
||||
|
||||
export type NavRailState = {
|
||||
readonly sessions: readonly NavRailSession[];
|
||||
readonly account: NavRailAccount | null;
|
||||
/** True once both requests have settled, however they settled. */
|
||||
readonly settled: boolean;
|
||||
/** The viewer is not signed in; the rail degrades to brand plus a login link. */
|
||||
readonly signedOut: boolean;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id?: unknown;
|
||||
title?: unknown;
|
||||
pinned?: unknown;
|
||||
updated_at?: unknown;
|
||||
};
|
||||
|
||||
function toSession(row: SessionRow): NavRailSession | null {
|
||||
if (typeof row.id !== "string" || !row.id) return null;
|
||||
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
|
||||
return {
|
||||
id: row.id,
|
||||
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
|
||||
pinned: row.pinned === true,
|
||||
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNavRail(): NavRailState {
|
||||
const [sessions, setSessions] = useState<readonly NavRailSession[]>([]);
|
||||
const [account, setAccount] = useState<NavRailAccount | null>(null);
|
||||
const [settled, setSettled] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (typeof fetch !== "function") {
|
||||
setSettled(true);
|
||||
return;
|
||||
}
|
||||
const [sessionResult, accountResult] = await Promise.allSettled([
|
||||
fetch("/api/sessions?limit=40", { signal: controller.signal }),
|
||||
fetch("/api/account", { signal: controller.signal }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
|
||||
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
|
||||
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
|
||||
if (!cancelled) setSessions(rows.map(toSession).filter((item): item is NavRailSession => item !== null));
|
||||
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
|
||||
if (!cancelled) setSignedOut(true);
|
||||
}
|
||||
|
||||
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
|
||||
const body = await accountResult.value.json().catch(() => null) as {
|
||||
credits?: unknown;
|
||||
user?: { email?: unknown };
|
||||
profile?: { name?: unknown };
|
||||
} | null;
|
||||
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
if (!cancelled) {
|
||||
setAccount({
|
||||
name: name || email || "账户",
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: typeof body?.credits === "number" ? body.credits : 0,
|
||||
});
|
||||
}
|
||||
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
|
||||
if (!cancelled) setSignedOut(true);
|
||||
}
|
||||
|
||||
if (!cancelled) setSettled(true);
|
||||
}
|
||||
|
||||
void load().catch(() => {
|
||||
if (!cancelled) setSettled(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { sessions, account, settled, signedOut };
|
||||
}
|
||||
@@ -127,21 +127,21 @@ test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的
|
||||
});
|
||||
|
||||
test("mobile width keeps two centre lines and moves the full parameter card below the chart", () => {
|
||||
assert.match(
|
||||
globalStyles,
|
||||
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-compact \{ display: block; \}/,
|
||||
);
|
||||
assert.match(
|
||||
globalStyles,
|
||||
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-extra \{ display: none; \}/,
|
||||
);
|
||||
assert.match(
|
||||
globalStyles,
|
||||
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-params-below \{ display: block; \}/,
|
||||
// 原值:三条断言都以 `@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; \}/);
|
||||
assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/);
|
||||
assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/);
|
||||
assert.match(cssDeclarations(".chart-page-back", globalStyles), /min-height:\s*44px/);
|
||||
assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/);
|
||||
});
|
||||
|
||||
@@ -157,7 +157,12 @@ test("an incomplete profile page has no loading animation", () => {
|
||||
test("the chart page shell is visible before the natal chart arrives", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view: null }));
|
||||
assert.match(markup, />星盘</);
|
||||
assert.match(markup, /返回对话/);
|
||||
// 原值:断言页面上有「返回对话」链接
|
||||
// 新值:断言常驻侧栏在场(「新建对话」是它的第一项)
|
||||
// 原因:星盘页并入 app 外壳后侧栏常驻,回对话不再依赖一个一次性链接;
|
||||
// 这比原断言更强——它要求的是整条导航都在,而不只是一个返回按钮。
|
||||
assert.match(markup, /新建对话/);
|
||||
assert.doesNotMatch(markup, /返回对话/);
|
||||
assert.match(markup, /这一张盘还没拿到/);
|
||||
assert.doesNotMatch(markup, /正在加载|骨架|spinner|打开即有/i);
|
||||
assert.doesNotMatch(markup, /chart-page-eyebrow/);
|
||||
|
||||
@@ -196,11 +196,16 @@ test("global report copy uses the canonical profile, allows an unrectified minut
|
||||
});
|
||||
|
||||
test("report centre and ready reader scroll inside the chat shell lock", () => {
|
||||
const centre = cssDeclarations(".report-center-shell", globalStyles);
|
||||
// 原值:`.report-center-shell`(报告中心自己的全屏滚动容器)
|
||||
// 新值:`.secondary-panel > :not(.chat-header)`——报告中心并入 app 外壳后,
|
||||
// 滚动所有权归外壳的内容区,三个次级页共用一条规则。
|
||||
// 原因:T4.2。「页面在外壳的高度锁内部滚动、不自己撑高」这条要求没变。
|
||||
const centre = cssDeclarations(".secondary-panel > :not(.chat-header)", globalStyles);
|
||||
const reader = cssDeclarations(".personal-report-reader", globalStyles);
|
||||
assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/);
|
||||
assert.match(centre, /height:\s*100%/);
|
||||
assert.match(centre, /overflow-y:\s*auto/);
|
||||
// 高度由 .chat-panel 的 grid 行给定,内容区自己不再声明 height。
|
||||
assert.match(cssDeclarations(".chat-panel", globalStyles), /grid-template-rows:\s*46px minmax\(0, 1fr\) auto/);
|
||||
assert.match(centre, /-webkit-overflow-scrolling:\s*touch/);
|
||||
assert.doesNotMatch(centre, /min-height:\s*100dvh/);
|
||||
assert.match(reader, /height:\s*100%/);
|
||||
|
||||
@@ -77,10 +77,14 @@ test("print pins the palette back to light so paper is always paper", () => {
|
||||
// 原因:BUG-737——那个"衬线栈"里的 Tiempos Headline 从未加载,中文标题实际落到宋体。
|
||||
// --font-display 现在是无衬线,400 在大字号上发虚,display 层级改由字重承担。
|
||||
test("report titles use the display stack at the display weight", () => {
|
||||
const centreTitle = cssDeclarations(".report-center-hero h1");
|
||||
// 原值:三处标题——报告中心页标题 `.report-center-hero h1`、封面、章节
|
||||
// 新值:两处——中心页标题已随 hero 移进 app 外壳的 46px 顶栏(T4.2),
|
||||
// 它现在走 `.chat-header strong`,由 chat-header 的规则统一管。
|
||||
// 原因:报告中心不再是有自己 hero 的独立页。封面与章节标题是报告纸面自己的
|
||||
// 排版,不受影响,仍然逐条断。
|
||||
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) {
|
||||
for (const [name, body] of [["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*500/, `${name} title must stay weight 500`);
|
||||
}
|
||||
@@ -89,5 +93,8 @@ test("report titles use the display stack at the display weight", () => {
|
||||
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/);
|
||||
// 原值:报告中心「返回对话」按钮的 44px 触摸目标
|
||||
// 新值:删除该断言
|
||||
// 原因:报告中心并入 app 外壳,返回路径是常驻侧栏(T4.2),
|
||||
// 它的触摸目标由 sidebar-contract 与 touch-target-contract 管。
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ test("tablet CSS cuts at 1023 so it matches sidebarViewportForWidth", () => {
|
||||
});
|
||||
|
||||
test("report centre and reader share the global mobile cut", () => {
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.report-center-shell/);
|
||||
// 原值 `.report-center-shell` / 新值 `.report-center-body`
|
||||
// 原因:报告中心并入 app 外壳(T4.2),页面根不再是自己的全屏滚动容器;
|
||||
// 「用全局 767 断点、不自立断点」这条要求没变,下面两条 doesNotMatch 仍在守。
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.report-center-body/);
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.personal-report-reader-body/);
|
||||
assert.doesNotMatch(css, /@media \(max-width: 720px\)/);
|
||||
assert.doesNotMatch(css, /@media \(max-width: 760px\)/);
|
||||
@@ -42,6 +45,7 @@ test("report TOC keeps a content-width exception at 860px", () => {
|
||||
});
|
||||
|
||||
test("ephemeris page stacks at the global mobile cut", () => {
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-shell/);
|
||||
// 原值 `.ephemeris-shell` / 新值 `.ephemeris-body` / 原因同上。
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-body/);
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga \{[\s\S]*grid-template-columns:\s*1fr/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user