# Jyotisha Web Design System
This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract.
## 读者版年运与正文投影(2026-09-25)
年主、Muntha 星座、年度上升与返照时刻分别来自对应年度的真实计算字段,不从年主对象猜补。两种口径不一致时并列注明,不合成单一结论;本地结果不等于完成外部验证。姓名缺失不显示空行,五要素字段使用中文列名。
Bhava Bala 保留完整十二宫力量表;Score / Weight 等领域表头不单独触发整表删除。内部表连同紧邻说明段移除,标题留下,避免后续正常表归错章节。PL9 审计词和长英文段过滤只用于报告正文,聊天导出与安全图盘沿用既有安全规则。不增加入口、等待动画、布局或颜色。
## 报告核对层与独立附录(2026-09-23)
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
事实表用中文列名和领域行列呈现,不把引擎路径当行名。主运、分运与小运、SAV 与 BAV、年度位置与 Saham 分为同组子表;缺失的年度行星位置明确说明,不用本命盘替代。机器溯源路径保留在数据中、不渲染。打印或保存 PDF 前自动展开此核对层全部折叠组,完成或取消后恢复原状态;打印解除表格横向滚动裁剪、重复表头,宽 BAV 表仍保留十二星座列。不新增打印入口或等待动画。
## 校正跨午夜日期与采用(2026-09-20)
复用现有时段选择卡追问一次午夜前/后;不知道或跳过保留填报当天凌晨与深夜两段,不补白昼或次日。选项用中文钟点,继续遵守题卡禁止暴露数字钟点的现有校验,不新增入口、状态或等待动画。
区间交付卡按实际日期序号显示分段;跨申报日明确「前一天 / 后一天」,候选列同时给实际日期与钟点,说明「采用后,排盘使用这一日期和时间;原填报日期保留。」图表库默认显示当前采用的有效日期;原申报日期仍独立保存,不猜补历史结果。沿用原按钮、只读门与代表分钟免责声明,不把采用写成唯一分钟确认。文案已对照 VOICE 的服务端数字来源、短句与采用不等于确认原则。
本命上升的分段展示给出完整本地日历日期与钟点,避免同一个钟点被误读为同一天;历史缺日期的结果仍显示 HH:MM,不猜补。沿用既有布局与颜色,不增加入口、状态或动画。
## 校正历史结果身份只读(2026-09-20)
历史打开、刷新与比较返回的结果若无法核实为当前算法及策略,消息区顶部复用 `rectification-pending-note`、`role="status"` 显示「按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。」保留原历史内容和来源,不出现强制重算入口;关闭旧结果选项、候选采用及普通输入,输入提示为「当前结果仅供查看」。可信当前身份已知但与旧结果不同,非终止会话可显式点「重新比较」,复用既有发送链路;身份未知时无此入口,服务端再次探测失败也不能重算。此状态不等同 Case 已结束,不显示误导的结束提示或额外「再次校正」按钮。真正终止的历史仍沿用原结束操作。服务端独立核验所有结果写入,不以界面禁用代替权限。无新颜色、加载动画或 Home 状态。
## 普通对话寒暄轮(2026-09-20)
服务端标记 `responseKind: smalltalk` 的回复仅展示正文与既有头像/消息操作,不展示思考面板、活动步骤、已完成步骤或技法证据;刷新后的历史也遵循此规则。分类尚未确定、且没有任何真实 activity/thinking/timeline 时,保留消息位置,不虚构「正在分析」或步骤,不另加 spinner。咨询收到真实活动后恢复既有呈现。完成后读取服务端账户余额,寒暄预留点数原额退回,净点数不变;不增加 Home 状态。
## 1. Atmosphere & Identity
Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet enough for reflective conversation. The signature is a pale parchment canvas, translucent warm-gray navigation, fine neutral hairlines, and restrained deep-brown accents. Serif display type gives astrological guidance the gravity of a considered essay rather than a generic chatbot.
## 2. Color
### Palette
| Role | Token | Value | Usage |
|---|---|---:|---|
| Canvas | `--color-canvas` | `#fbfaf7` | Main reading surface, inputs, light controls |
| Page floor | `--color-canvas-soft` | `#f3f2ee` | App background and quiet secondary bands |
| Warm surface | `--color-canvas-muted` | `#ebe9e3` | Cards, user messages, table headings |
| Strong neutral | `--color-canvas-strong` | `#e1ded6` | Pressed and emphasized neutral surfaces |
| Sidebar glass | `--color-sidebar` | `rgba(235, 233, 227, .86)` | Desktop navigation only |
| Sidebar solid | `--color-sidebar-solid` | `#ebe9e3` | Mobile drawer, and any reduced-transparency surface |
| Selected surface | `--color-selected` | `rgba(255, 255, 255, .62)` | Current navigation and raised light rows |
| Ink | `--color-ink` | `#1d1d1f` | Headlines and primary text |
| Body | `--color-ink-strong` | `#32322f` | Strong body copy |
| Secondary text | `--color-ink-secondary` | `#5f5f59` | Supporting copy and labels, section headings |
| Tertiary text | `--color-ink-tertiary` | `#6a6963` | Hints, metadata, empty-state copy |
| Primary action (text rank) | `--color-action` | `#a9583e` | Links, tinted labels, icons — anything the user reads |
| Primary action (fill rank) | `--color-action-strong` | `#cc785c` | Button and avatar fills, the active session rail, rings, carets |
| Primary soft | `--color-action-soft` | `#f7ece5` | Editorial emphasis without a dark block |
| Primary active | `--color-action-hover` | `#8f4630` | Hover and pressed action |
| Dark punctuation | `--color-surface-dark` | `#1d1d1f` | Compact primary buttons and user-authored emphasis only |
| On dark | `--color-on-dark` | `#fbfaf7` | Text on compact dark controls |
| Hairline | `--color-border` | `#d8d6cf` | Default separators and controls |
| Strong hairline | `--color-border-strong` | `#b8b5ad` | Inputs and higher-contrast dividers |
| Success | `--color-success` | `#28633e` | Available and completed states |
| Warning | `--color-warning` | `#b07b22` | Caution states |
| Error | `--color-danger` | `#9a2f2f` | Errors and destructive actions |
| Accessible focus | `--color-focus` | `#cc785c` | Keyboard focus and input focus |
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` | `#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` | **Deliberately not the app accent.** Kept at the old deep brown; see the note below. |
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.
### Where the action color appears
The accent has **two ranks**, because one hex cannot do both jobs. Claude coral
`#cc785c` measures **3.14:1** against the `#fbfaf7` canvas — fine for a fill or a
border, not for text. And 53 of the 62 accent call sites are `color:`, including
the markdown links inside assistant answers.
- `--color-action` (`#a9583e`) is the **text and icon rank**: 4.85:1 on canvas.
It is the same coral darkened, not the old brown returning.
- `--color-action-strong` (`#cc785c`) is the **fill rank**, where the 3:1
non-text threshold applies: button and avatar fills, the send/stop control, the
2px active-session rail, the focus ring, the typing caret, checked radios.
One known gap, accepted by the product owner: white on `#cc785c` is **3.28:1**,
below AA for normal text. It sits on `.button-primary` and the two avatar
circles. claude.ai ships the same value, and "looks like Claude" was the stated
goal. Body text is unaffected — that is what `--color-action` protects.
Scarce is still a budget. The sidebar spends it in exactly three places, and a
fourth needs a reason:
1. `新建对话` — the one high-signal action in the nav, as tinted text and icon on
a transparent row (`--color-action`, deepening to `--color-action-hover`).
Never a filled block: a full-width terracotta surface would break the
ninety-percent-light rule.
2. The 2px bar on the active session (`--sidebar-ring`, which follows
`--color-focus`).
3. The profile initial's circle (`--color-action-strong`), which only renders
when the account has no uploaded avatar.
2 and 3 both disappear on a new account with an avatar — no sessions, no
initial — which is how the sidebar ended up with no accent pixel at all. Item 1
is what guarantees the nav still has a visual anchor in that state. `我的报告`
and every section label stay neutral on purpose; giving them color too would
flatten the hierarchy the accent exists to create.
**The report is not on this system.** `--report-accent` stays `#85432f` while the
app moved to coral. The report is a printed-paper surface, not app chrome; coral
reads washed out on the cream sheet. The two palettes are now independent on
purpose — do not "fix" the mismatch.
The glass surface assumes a light backdrop. The mobile drawer floats above the
scrim instead, so the translucency darkened it to `#E3E1DC` and flattened the
warmth; the drawer takes `--color-sidebar-solid` and drops `backdrop-filter`.
Hierarchy inside the nav comes from ink rank, not hue: section headings sit on
`--color-ink-secondary`, their body and empty-state copy on
`--color-ink-tertiary`. Both used to share tertiary, which is why the nav read
as one flat grey. Empty sections name the next step in that same neutral copy
rather than adding a second tinted call to action.
### Dark theme
The dark theme is a role-preserving restatement of the palette, not an inversion.
Every themeable token is redefined; `frontend/tests/dark-theme-contract.test.ts`
fails if one is missed.
Three states, the standard pattern: with no attribute the page follows the OS
through `prefers-color-scheme`; `data-theme="dark"` or `data-theme="light"` on the
root element pins a choice and wins in both directions. Plain CSS cannot share a
declaration list, so the dark palette is written twice — once inside the media
query, once under the attribute — and a contract test asserts the two copies stay
identical.
| Role | Light | Dark | Why it is not an inversion |
|---|---|---|---|
| Page floor | `#f3f2ee` | `#1a1a19` | On light, raised surfaces step *down* in lightness; on dark they step *up*. The floor is the darkest surface in dark mode, the second-lightest in light mode. |
| Reading surface | `#fbfaf7` | `#262624` | Warm, never neutral black. A cool grey would change the product's identity rather than its brightness. |
| Warm card | `#ebe9e3` | `#30302d` | One step above the reading surface in both themes, by opposite directions. |
| Primary action | `#a9583e` | `#d78064` | Two ranks on light (text `#a9583e`, fill `#cc785c`) collapse to one on dark: `#d78064` already reads on `#262624`, where the light values do not. |
| Action hover | `#8f4630` | `#e59273` | Hover darkens on light and lightens on dark. |
| Dark punctuation | `#1d1d1f` | `#f2f0ea` | A dark block on a light page becomes a light block on a dark page; the token name describes the light-theme value, the role is "highest-contrast block". |
| Semantic hues | `#9a2f2f` / `#28633e` / `#b07b22` | `#e08573` / `#74b189` / `#d8a758` | Lifted for contrast; their `-muted` pairs stop being pale washes and become dark tints of the same hue. |
| Scrim | `rgba(29,29,31,.34)` | `rgba(0,0,0,.58)` | A scrim has to darken a surface that is already dark. |
| Shadow | low alpha | ~5× alpha | Without a light ground to fall on, a soft shadow registers as nothing. |
Contrast is asserted, not assumed: eight inks (ink, ink-strong, ink-secondary,
ink-tertiary, action, danger, success, warning) must all clear 4.5:1 against
four dark surfaces (canvas-soft, canvas, canvas-muted, sidebar-solid). The four
neutral surfaces must increase in lightness in floor → canvas → muted → strong
order. Adding a text or surface token means adding it to that 32-pair matrix;
checking only the reading surface is not enough.
**Surfaces that do not follow the theme, on purpose:**
- The payment QR keeps literal white in both themes. Scanners need the light
modules to actually be light.
- `@media print` keeps white paper.
- The admin is antd + Refine and stays light. Re-skinning `admin.css` alone would
give a dark shell around light antd components, which reads as broken; doing it
properly means switching antd to `theme.darkAlgorithm` as well, and that is a
separate change.
**The control** is a radio group inside the account menu (avatar → 外观), so it
keeps menu semantics: arrow keys reach it and the current choice is announced.
Three options, matching the three states — 浅色, 深色, 跟随系统. Picking one does
not close the menu, so the change is visible where it was made.
"跟随系统" **removes** `data-theme` rather than writing a third value; the media
query has nothing to match otherwise. The choice is stored under `jyotisha-theme`
and re-applied by a synchronous script at the top of `
` — it must not be
deferred, or every load flashes the other theme before hydration. Blocked storage
degrades to following the OS. The preference is per-device, not per-account, and
is read through an external store so a change in one tab reaches the others.
## 3. Typography
### Font stacks
- Display: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif` — the same stack as body. Display rank comes from **weight and tracking, not family**: display and title rules run at weight 500 against body's 400.
The old value led with `"Tiempos Headline", "Songti SC", STSong, …, serif`. Tiempos Headline is an Anthropic licensed face this app has never loaded (no `@font-face`, nothing in `public/`, `layout.tsx` vendors only Inter), so in production every CJK heading fell through to **Songti SC on Apple and SimSun on Windows** — 20 rules wide, including the `h2`/`h3` inside assistant answers. See BUG-737.
A Latin-serif-first stack (`"Newsreader", "PingFang SC", …`) was measured and rejected: the Newsreader latin variable subset is 132 KB — 2.7× the entire Inter file — to serif one wordmark, and it splits a mixed heading such as “D10 事业盘怎么读” into two scripts, which reads as a font-loading failure. **CJK never takes a serif here.**
- Body/UI: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. `StyreneB` was removed from the head of this stack for the same reason as Tiempos: it never loaded, so it was dead configuration that made the stack look intentional.
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`.
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand.
### Scale
| Token | Size | Weight | Line height | Tracking | Usage |
|---|---:|---:|---:|---:|---|
| `--type-display-lg` | `48px` | 400 | 1.1 | `-1px` | Desktop page titles |
| `--type-display-md` | `36px` | 400 | 1.15 | `-.5px` | Dialog and mobile page titles |
| `--type-display-sm` | `28px` | 400 | 1.2 | `-.3px` | Section titles |
| `--type-title-lg` | `22px` | 500 | 1.3 | 0 | Prominent UI titles |
| `--type-title-md` | `18px` | 500 | 1.4 | 0 | Card and message headings |
| `--type-title-sm` | `16px` | 500 | 1.4 | 0 | List titles |
| `--type-body-md` | `16px` | 400 | 1.55 | 0 | Default reading text |
| `--type-body-sm` | `14px` | 400 | 1.55 | 0 | Compact UI text |
| `--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; 12–13px 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.
## 4. Spacing & Layout
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`. `--consult-think-answer-gap` is `var(--space-3)` (12px): it is the only gap between the thinking bar and the answer body. Both `.consultation-thinking-report` and `.message-stage-and-answer` use it; do not write a second value on either path.
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.
- Layout breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail. CSS `@media` width cuts must follow `sidebarViewportForWidth` in `frontend/src/lib/sidebar-state.ts` (768 / 1024), not the other way around. The allowlist lives as a comment at the top of `globals.css` and is locked by `frontend/tests/viewport-breakpoint-contract.test.ts`.
- Allowed `@media` widths: 480 (small phone), 640/641 (content grids: membership, rectification candidates, intake card), 767/768 (mobile/tablet), 860 (report TOC, content width of the third column), 1023/1024 (tablet/desktop). Do not add a new width without updating that list and the contract test in the same change.
- All full-height surfaces use `100dvh`, but never as the only declaration. A `dvh` length is dropped whole by an engine that does not know the unit, and a `height` that vanishes falls back to the content height — which is how the settings dialog started resizing per pane (BUG-698). Write the plain `vh` value as the base and upgrade inside `@supports (height: 1dvh)`. Do **not** use the duplicate-declaration form `height: 100vh; height: 100dvh;`: Lightning CSS (Tailwind v4's minifier) collapses duplicate declarations of one property and keeps only the last, so the fallback never reaches the browser. Locked by `frontend/tests/viewport-unit-fallback-contract.test.ts`. Touch targets: on a coarse pointer the hit is 44×44; the visual control may stay smaller. Message-action icons stay 26×26; a fine pointer keeps the 27×34 overlay, a coarse pointer opens gap and bottom margin to 20px so a 44×44 overlay does not eat the next button or the follow-up pills. Locked by `frontend/tests/touch-target-contract.test.ts`.
## 5. Components
### Brand mark
- **Structure:** image-backed mark plus serif wordmark.
- **Variants:** light canvas, dark sidebar.
- **States:** static; never animated.
- **Accessibility:** decorative image is hidden when adjacent text names the product.
### Button
- **Implementation:** report surfaces and the billing pane 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.
- **Motion:** 120ms transform/color; active translates by 1px or scales to .98.
### Input and composer
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
### Answer start anchor
Both chat surfaces pin a new turn at its head instead of following the last streamed token.
- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
- **Spacer:** the last assistant row's `min-height` is `calc(var(--conversation-viewport) - var(--latest-turn-head-height))`, both variables written onto the scroller by `useConversationScrollAnchor`. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
- **History:** opening another session still lands on the newest content once.
- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
- **Visibility:** shown when the last turn extends more than 96px below the fold — either because the reader scrolled up, or because this turn grew past the viewport while pinned at its head — and hidden again once they return to the bottom or press it. Sending a question pins the turn head; pressing the control re-anchors to the bottom.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
### Ayanamsa preference
The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa radios. Personal 个人资料 stays account basics only.
- **Structure:** four 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
- **Values:** `raman` (default) / `lahiri` / `kp` / `true_pushya`. Copy lives in `frontend/src/lib/ayanamsa.ts`.
- **Hint:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
- **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`.
- **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute.
### Rectification surface
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
- **Reveal:** the surface mounts once per session/Case binding. Opening a Case (homepage card, sidebar row, deep link or refresh) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal (`RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` is `BOOTSTRAP_PREPARE_TIMEOUT_MS`); a session selected at bootstrap is hydrated during the prepare phase. The entry shows a static note meanwhile (card footer “正在打开…”, sidebar row “打开中”, `cursor: progress`) and the previous view stays put. Turns and snapshot are initial state; anything that arrives later is a prop or state update, never a remount. A late or failed read still reveals, with the composer notice “校正记录没有完全加载,可以继续”.
- **States** — what the transcript's trailing entry and the composer show:
| State | Trailing entry | Composer |
|---|---|---|
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible |
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
| `question-live` | the asking message carries the embedded card or the spoken stem. An active spoken or choice focus without `asked_turn_id` hangs on the last assistant message, same avatar column | enabled, placeholder points at the card |
| `question-gap`, persisted question | only when no assistant message can carry the prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled |
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap. Never while the latest assistant message still carries an unanswerable tap question — that is the repair exit below (BUG-917) | enabled, placeholder “再说一件带年月的事” |
| `question-gap`, dead card on the last message | the latest assistant message carries a tap question with no answer and no live card (its focus was superseded, or GET has no card): the card is not drawn at all, and the gap is the repair exit “没有拿到下一个问题。” + “接着问”. A greyed, unclickable A/B/C/D never appears next to the collect-wait placeholder | enabled, generic placeholder |
| `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. The card appears once every collect line has been asked (refresh, guided windows, the skip retry, the seven targeted kinds) or the reader said there is nothing more to add. The precision gate (range ≤ 10 minutes, top-two gap > 3 points, no exact tie) is reported as `precision_gate_met` but never gates the card on its own: meeting it skips no remaining line, and failing it adds no marker to the card once there is nothing left to ask. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled |
| `guided-collect` | a named yes/no card then an entry card. A window whose boundary track carries a domain (D9 / D10 Narayana) asks “YYYY 年 M 到 M 月之间,有没有<那一类事>?”; every other window asks openly — “YYYY 年 M 到 M 月之间,有没有什么事,比如?” — and one window is asked once, whatever label it carries. The entry card is seven type chips (an open window defaults to the first still-open kind) plus a month-granularity date picker (day optional, years from birth year to this year); submitting writes “YYYY 年 M 月(D 日),<领域标签>方面有一件事”, never the question’s example list. Composer stays enabled. | enabled (typing still records), stop visible |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled |
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a repair button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
- **Board:** before any candidate exists the header clock shows the declared birth minute and the body names it by source — “出生记录时间 HH:MM”, “你填的大概时间 HH:MM”, or “你给的时间段” — then “回答几个问题后,这里会显示宫位随时间的变化.”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. The range card may add a caption comparing the declared clock to the current range (“与你填的大概时间相差 N 分钟”, or a hospital-record offset with no preference).
- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the repair and start actions are real 44px buttons.
### Birth time intake
- **Structure:** birth date, then three radio rows for “你对这个时间有多确定”——医院记录精确到分钟、家人记得大概时间、只知道时段或完全不知道。家人那档再给四个范围按钮(差不多准 / 前后半小时 / 前后一小时 / 前后两小时 = ±15/30/60/120)。存量 `family_exact` 档案显示成「差不多准」。之后只露出该档需要的钟点、时段或线索字段。
- **Surface:** choice rows use the warm canvas and hairline system; the selected row uses `--color-action-soft` with a deep-brown border, never a dark promotional card.
- **States:** no source selected, source selected, source-specific details incomplete, ready to continue, assessing, rectifying, candidate saved, confirmed.
- **Copy:** labels describe what the user actually knows. “完全不清楚” starts rectification from remembered events and compares declared periods before any minute grid; it does not defer correction. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time.
- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable.
- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation.
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title “目前范围 HH:MM–HH:MM(对照了 N 件经历)”, a caption “还能再收窄:如果记得 …” under the title while collect lines remain open, up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. When the top two columns are within 3 percentage points and the seven collect lines are closed, the card body (not the caption) invites one more dated event of any kind, with examples outside those lines. Collect copy “现在还剩 HH:MM–HH:MM 里 N 个候选” uses the same `credible_range` as the timeline “目前范围”, not the first/last active candidate minute. An eight-method report sits in a closed `` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect does not render skip chips; typing 「没有」 still declines the domain and 「记不清」 still skips it. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy.
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
- **Guided responsive/accessibility contract:** body copy remains at least 14px, labels at least 12px, and all controls at least 44px. Focus is always visible, the composer is keyboard operable, semantic Chinese phrases remain together at 390px, and reduced-motion removes entrance translation while retaining state changes.
### Birth place picker
- **Composition:** three cascading Base UI Selects — province, city, district — over the bundled China administrative dataset. There is no free-text search and no overseas provider in this surface.
- **Level collapsing:** a level that offers no choice is not rendered. Municipalities and special administrative regions skip the repeated city level; prefecture cities without districts complete at the city; a province with neither lower level completes at the province.
- **Unsure escape hatch:** any city that has districts leads its district list with “不确定,用市区中心”, so browsing never dead-ends. A stored empty district resumes as that choice.
- **Precision copy:** the picker states that county-level precision is enough for a chart, so a village or township birth is not treated as missing data.
- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke.
- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place.
- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again.
- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region.
### Birth date picker
- **Composition:** shadcn outline Button trigger, Base UI Popover, and a single-select React DayPicker Calendar.
- **Range:** local dates from 1900-01-01 through today; future dates are disabled. Month and year dropdowns provide direct navigation, with newest years first.
- **Value:** display Chinese long dates while emitting the existing `YYYY-MM-DD` profile value without UTC conversion.
- **States:** empty, open, selected, focus-visible, disabled confirmed profile, and unavailable date.
- **Accessibility:** visible label, explicit trigger naming, 44px targets, keyboard calendar navigation, focus return, and collision-safe popup positioning.
### Model selector
- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state.
- **Width:** the popover is capped at 180px with viewport collision protection.
- **Surface:** canvas trigger with no card treatment; the popover uses the elevated canvas recipe, warm hairlines, and one selected-surface row. The action color is reserved for the selected indicator and focus ring.
- **States:** closed, open, hover, focus-visible, selected, disabled, and unavailable catalog. Selecting a model closes the popover and only affects later messages in the current conversation.
- **Accessibility:** the trigger and every option meet the 44px touch target; options are a native radio group, with a small roving-focus fallback so Tab, arrow keys, Space, and screen readers consistently expose the selected model inside the popover.
- **Motion:** the popup enters over 120ms with opacity and a 4px vertical translation; reduced-motion removes the translation.
### Chat header
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
- Ordinary consultation: the person is a quiet button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation.
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
- Loading reads `人物列表还在读`. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
- Rectification, reports, and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The consultation button stays on narrow screens and ellipsizes so the header remains one line.
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring.
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
### Navigation item
- **Structure:** title is the session name. Rectification titles show the result: `生时校正 · HH:MM` when a time is confirmed (confirmed wins over accepted) or only accepted, `生时校正 · HH:MM–HH:MM` (en dash) when only `candidate_range` is a clock pair, otherwise `生时校正`. Daily rhythm stays `今日节奏 · M月D日`. Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `updated_at` (last activity), plus ` · ` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. Display, sort, grouping, and cursor all use `updated_at`.
- **States:** default, hover, current, focus, disabled.
- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`.
- **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles.
- **Surface:** translucent warm-gray sidebar. Current and hover fill the whole `.session-row` — title and the ⋯ share one chip. The menu trigger has no canvas of its own.
### Sidebar shell
- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. `/`、`/chart`、`/ephemeris`、`/reports` share one `(app)` layout: one `SessionListProvider` and one `SidebarProvider`. The session list is fetched once per tab; walking between those four pages does not remount the sidebar or refetch `/api/sessions`.
- **Home registers shell controls** (new chat, select session, rename, pin, share, delete, account menu). The other three pages leave `controls` unset, so rows are links to `/?c=`.
- **Trigger placement:** the single visible collapse/expand trigger sits beside the active session title in the chat header. The sidebar brand row has no duplicate trigger.
- **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail.
- **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded.
- **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape.
- **Collapsed content:** logo, new-chat action, the page actions, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons.
- **Expanded content:** the header actions (new chat, 星盘, 星历, 我的报告), then **one flat list** under a single 「最近」 heading: pinned sessions as the first labelled group, then history grouped by recency.
It used to be three stacked sections — a 星盘列表 chip grid and two `` wrappers for 收藏对话 / 历史对话 — so the nav carried two collapse affordances before the first session row, and neither had anything worth collapsing. The chip grid went entirely: every chip, and the add button beside it, opened the same chart-library dialog that the account menu already offers, so a list of names provided no navigation. Chart data is managed from one place now (account menu → 星盘资料).
The list sits at the nav's own gutter rather than indenting past a heading icon, because there is no longer a heading to align to. Actions and session rows share 18px icons, caption/body type, and ink/muted tokens; the 「收藏」 group label carries a 13px accent star as an inline marker, not a heading icon. Empty untitled sessions stay off the history list. Pin is favorite. Session row menu is rename, pin, share, and delete — not archive. The history list loads 40 rows at a time and silently appends the next page at the bottom.
- **Empty state:** one message, not one per section. 「暂无对话,点上方「新建对话」开始」 shows only when there is nothing at all; with no pins the 收藏 group simply does not render, so nothing says 「还没有收藏」 about a section that is not on screen.
- **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner.
- **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar.
- **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state.
- **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support.
- **State:** the desktop and tablet collapse state is remembered per browser under the `sidebar_state` localStorage key, so collapsing on `/` survives the walk to `/chart` and back. Nothing is stored until someone actually toggles it; with nothing stored the breakpoint defaults stand. The mobile drawer stays session-local and is never remembered — reopening it on every navigation is not a preference anyone set. localStorage rather than the shadcn `sidebar_state` cookie because `/`, `/chart` and `/ephemeris` are static routes and a server-read cookie would opt all three out of static rendering; the stored value is therefore applied in the same post-mount effect that already applied the breakpoint default, and costs no frame that default did not already cost.
### Message
- **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:** neither role carries an avatar. The assistant reply is plain editorial text starting at the column edge; the user's turn is a right-aligned tinted bubble. The 32px Jyotisha logo used to sit beside every assistant message — at one avatar per turn it became the most repeated element in a long transcript, and it pushed the reply 44px in from the column the user reads down. `--assistant-content-inset` is `0px` now, so the follow-up chips and the run timeline line up with the reply text itself rather than with a mark that is gone.
- **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.
#### Streaming states
Every assistant reply moves through the same states on both chat surfaces, and each state has exactly one visual. The step timeline (`ConsultationRunTimeline`) is the only activity surface. Consultation thinking is complete `think.step` items (v1 `thinking.delta` still renders for one release). Rectification never shows thinking text: provider reasoning is dropped at the public boundary.
| State | When | Visible | Transition in |
|---|---|---|---|
| `queued` | request sent, no server event yet | timeline open with one live row “正在处理…” (spinner + shimmer label), summary “正在分析” | row enters with the message |
| `loading-method` | `skill.started` | live method row | label swap, 120ms fade |
| `calculating` | `tool.started` / chart activity | live calculate row; completed rows above it show the check marker | label swap, 120ms fade |
| `thinking` | `think.plan` / `think.step` (and v1 `thinking.section` / `thinking.delta`) | live think row; complete step text inside the row | label swap, 120ms fade |
| `composing` | first `answer.delta` | live write row; answer text below the timeline, released per frame | answer paragraphs appear as text, no per-token animation |
| `settled` | `run.completed` | summary becomes “已完成 N 步”, timeline collapses in place unless the reader opened or closed it themselves; actions and follow-ups appear | 180ms height transition, 120ms label fade |
| `stopped` | the reader pressed stop | whatever streamed stays; a grey caption under the body says the run stopped and was not billed | none |
| `failed` | `run.failed`, network loss | whatever was received stays in place; the message carries no inline banner — the notice goes to the composer notice / error line | none |
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`, `/reports` and `/reports/[reportId]` share the chat
shell. 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:** `app/(app)/layout.tsx` = `SessionListProvider` →
`SidebarProvider` → `AppSidebar` → `SidebarInset.chat-panel.secondary-panel`
(home registers its own inset class). `/chart`, `/ephemeris` and `/reports`
share `SecondaryPageShell`: the same 46px `SecondaryHeader` (sidebar trigger,
page name, a reserved note slot so a late birth line cannot collapse the row,
up to two actions) and a `.secondary-page` body that always fills the
remaining viewport. Waiting copy is centered in that cell; swapping in the
tall content does not change the shell height. No spinner or 「正在加载」;
skeletons are limited to the chart-page plot positions (§15, 2026-09-24). First-screen data for the three pages lives in a module-level
cache; a second visit paints the last result immediately and refreshes in
the background. Sidebar `pointerenter` / `pointerdown` prefetches that data
(`` only prefetches JS). A tab that outlived a deploy compares
`NEXT_PUBLIC_GIT_COMMIT` with `/api/health` `.deployment.gitCommit` on
visibility and before navigation; a mismatch uses `window.location.assign`,
a match keeps the client router.
- **Separate data and shell subscriptions.** `SessionListProvider` owns both,
but Home reads only the session/account context and its stable registrar.
Only AppShell subscribes to shell registration; it passes the original
`children` through unchanged. Registering fresh controls/rows must not render
their producer again: that feedback loop can starve client navigation.
Business changes still publish current callbacks; Home unmount clears them.
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
product. `/` passes `controls`; the secondary layout does not, and without it
the same markup renders read-only. There used to be a second component for
these pages, and two components meant two shapes: its rows had no
`.session-main`, so they lost the 44px minimum, the row padding and the 2px
current-item marker, while `.session-row` still reserved a 44px column for a
menu button it never rendered.
- **What read-only drops — exactly three things.** The per-row menu trigger (and
with it the reserved column, via `.session-row[data-readonly="true"]`), the
footer chevron, and the account menu behind it. Everything else is the same
element, the same class and the same size: brand row, 新建对话, the three page
actions, the flat 「最近」 list, the 56px `.profile-trigger`. Rows are links to
`/?c=`; the footer is a link to `/`. Renaming, pinning, archiving and
deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback
layer, which is far more than these four routes need.
- **New chat is not a home link.** The read-only 新建对话 action links to
`/?new=1`; Home consumes that intent before activation and opens a fresh local
consultation. It clears `new`, any `c`, and the login-return stub. Before the
first question there is no saved session, no `?c=`, and no new history row.
The first question uses the existing persistence flow. A reserved older
consultation keeps recovering in the background without taking over this
explicit new-chat landing or leaving its startup recovery notice there.
The account footer
still links to `/`, and a bare-home refresh keeps its existing landing rule.
The same action and mobile drawer-close behavior are retained; no extra
button, copy, loading state, or visual treatment is introduced.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
`AppLink` on every page, including `/`, where they used to be a full document
load that threw away the React tree, the session list and the account. Only
`/login` stays a hard exit, and `persistLoginSessionReturn()` still stashes the
`?c=` before any of them. After a deploy, a stale tab's next `AppLink` is the
hard exit: `window.location.assign`, not a silent client-router no-op.
- **Loading:** the sidebar 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. A list read in the
last 60 seconds is reused from memory and shown in the first frame; the entry
is keyed by account, never persisted, dropped on any session write on `/` and
on any 401.
- **Signed out:** the sidebar 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:** 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 **row list** of reports.
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.
- **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12.
- **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut.
- **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`.
- **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line.
- **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow.
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. 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. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures remain in the open drawer; only success closes it and emits a toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
### Personal report reader
- **分块导出对话框 (2026-09-24):** native modal `