Files
Jyotisha/frontend/DESIGN.md
T
jesse-ux 6d81062be9
Independent Staging Quality Gate / validate (push) Canceled after 5m37s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(web): 校正会话上不再出现可用的普通输入框
send() 对校正会话不发 /api/consult,草稿保留并打开校正面。
普通输入框只有禁用态。咨询接口把类型不对改成 409 session_not_consultation。
删除和回退不再直接切到校正会话。
2026-09-17 20:06:25 +08:00

794 lines
98 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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 `<head>` — 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; 1213px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
## 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`.
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 7681023px, 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.
### 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 reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
- **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 月之间,有没有什么事,比如<two or three still-open kinds>?” — 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 questions 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:MMHH: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:MMHH: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 `<details>` 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 `分析对象:{盘名}`.
- The chart a session analyses is now a quiet `--color-ink-tertiary` chip beside the title (`.chat-header-chart`), and it drops out below 768px rather than wrapping the header to two rows. "分析对象" was internal vocabulary; the chart name alone carries the same fact.
- 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, optional subtitle for another person's chart, current-state marker.
- **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 uses a white glass surface and deep-brown marker.
### Sidebar shell
- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset.
- **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 `<details>` 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; archive stays in the session menu, not as a history-header toggle. 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; the rectification session projects its tool trace onto the same rows and differs only in that it never shows thinking text, because the server drops rectification `thinking.delta` 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` | `thinking.section` / `thinking.delta` | live think row; thinking text inside the row, paced per frame | 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/(secondary)/layout.tsx` = `SidebarProvider`
`AppSidebar` (read-only) → `SidebarInset.chat-panel.secondary-panel`. The
route group changes no URL; what it changes is that the four routes share
**one** shell instance, so moving between them neither remounts the sidebar
nor re-reads the session list. Each page contributes only `SecondaryHeader`:
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`).
- **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=<uuid>`; 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.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
`<Link>` 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.
- **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 carrying a 5px `currentColor` dot before the label. The dot is the change: the three states used to differ only by fill, which is a weak signal at caption size. Ready reads success, generating reads action-soft, failed reads danger. There is no spinner in the chip — the list polls, it does not perform.
- **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; rows use `--color-canvas`, a warm hairline, and `--radius-lg` on the list as a whole. No drop shadow.
- **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 “查看报告” as the primary document action with “导出报告(.md)” quiet beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 1030 秒”); 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. 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:** 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
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back, secondary print, primary “导出报告(.md)”) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 112. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
- **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px.
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy.
- **Accessibility:** back, print, and Markdown export are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
### Starter home
The empty state is the composer. It used to be a bordered hero card at
`clamp(34px, 5vw, 52px)` plus two 132px product cards — roughly 800px of content
sitting on top of the input, so the primary action was the last thing on the
page. Three parts now, in reading order:
- **Greeting** (`.starter-greeting-block`): a tertiary-ink salutation line and
one question at `clamp(26px, 3.2vw, 34px)`, weight 500, centred. It lives in
the conversation area with `align-content: end`, so it sits directly above the
composer rather than floating in the middle of the row.
- **Composer**: unchanged, and *in the same DOM position it occupies during a
live session*. Nothing remounts when the first message lands, so an in-flight
draft, focus, or queued card survives the transition. Its width is
`--session-column-width` in both states — the empty-state composer used to be
1040px against the session's 760px, so the input visibly narrowed the moment
a conversation started.
- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside
`.composer-wrap` *below* the composer. 36px pills, hairline, accent icon, one
word each. Below them one line merges the daily trend with the rectification
hint, and an accuracy-boundary line appears when no natal minute is available.
- **States:** default, hover, pressed, focus-visible, disabled, and `data-opening`
while a Case is being opened. Hover only moves the hairline; the surface tint
belongs to the pressed state, because a hover that shades the surface reads as
"selected" and sticks on touch. Opening a Case changes the cursor to `progress`
and nothing else — no spinner (unified-loading ruling).
- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal
questions as “事业 · …”, and rectification as “8月21日 · 生时校正”. Clicking the
daily pill starts a consultation session immediately; it does not place the
question in the composer. Private model instructions are selected by a closed
entrypoint identifier and expanded only on the server. There is no
“从一个主题开始” grid; leftover `guided_topic` values in stored sessions still
pin domains when replayed.
- **Rectification hint** keeps three server-driven branches — an unfinished case,
a finished one that can be redone (`rectificationCardAction === "restart"`),
and a first run. The branch comes from the entry summary, never the session
list.
- **Responsive:** the pill row wraps and stays centred; there is no
phone-specific layout, because there is no fixed-height card to collapse.
- **Accessibility:** each pill is one native button with a descriptive accessible
name (the full action label, not the one-word face). The daily hint keeps
`role="status"` while the trend is still arriving.
### Account popover
- **Structure:** identity header, then 个人资料 / 星盘资料 / 通用设置 / 账户与点数 (with the current balance), theme preference, divider, and logout.
- **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards.
- **States:** closed, open, hover, focus-visible, and logout confirmation.
- **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return.
- **Billing:** the 账户与点数 row opens the settings dialog on the billing pane. It does not leave the homepage.
### Settings dialog
- **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 176px and does not scroll; the title bar stays put; only the right-hand content pane scrolls. Logout stays a separate 400px confirmation.
- **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top.
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
- **Content width:** the 880px frame leaves roughly 690px of content, which is too wide for a single column of fields. Form panes (个人资料, 通用设置) cap their children at 440px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698).
- **Personal profile:** one row of avatar editing (48px preview, eight palettes, 换一个形象) plus nickname and login email. No duplicate 管理星盘资料 button.
- **Chart library:** list first (self row, other rows, 添加其他人). A row opens a detail with ← 星盘资料. Other details own 设为默认 / 删除 / 用于合盘 and that person's synastry history. The add form is a view, not an always-on stack.
- **States:** open, pane switch, list / self / other / add, saving, success, and error.
### Billing pane
- **Structure:** summary line (balance · membership / expiry), then tabs 会员套餐 / 点数包 / 兑换码 / 订单记录.
- **Plan card states:** exactly two, and they are mutually exclusive. `.is-current` (当前套餐) draws the `--color-action` border; `.is-recommended` (推荐, the monthly plan) draws the `--color-border-strong` border on the plain canvas. A card that is both takes 当前套餐 — its own action is 续费, not 立即购买 — so borders and fills can never stack. The badge in the card header says the same word. Mobile ordering (月卡 first below 640px) keys off `[data-plan-alias="monthly"]`, i.e. the plan's identity, not its state, so it survives the card becoming the current plan.
- **`?plan=` deep link:** names its target in copy, not in a third border layer. A `.membership-plan-note` line above the grid reads 「你从「年度会员」的链接过来的,就在下面。」, and the card takes `ref` / `tabIndex` / `aria-current` for the scroll and focus target only. It names the plan by the card's own title rather than a position: below 640px the monthly card takes `order: -1`, so an ordinal would point at the wrong card. Scrolling alone is not wayfinding here — at desktop widths all three cards are already in view, so a link that only scrolled pointed at nothing.
- **Actions:** recommended-plan purchase is `Button` default. Other plan and credit buys use `Button` outline. Cashier still opens with `window.open` in a new tab. Paying copy is 跳转收银台…; pending copy is 等待支付结果. Redeem is an inline form on its tab, not a nested dialog.
- **Deep links:** `/?settings=billing&source=…[&plan=…][&tab=…]` open this pane on `/` and are then stripped. `/membership` and `/membership/orders` redirect here. `&plan=` does **not** repaint the card (that used to be a third border plus an outline on top of the two states); it scrolls the card into view and focuses it, and nothing else.
- **Waiting:** the summary renders from the in-memory account. Package cards stay empty until `/api/payment/packages` arrives. No spinner, skeleton, or 「正在加载」.
### Logout dialog
- **Structure:** confirmation title and explanation, cancel action, and destructive confirm action.
- **Width:** 400px desktop maximum.
- **States:** open, signing out, and error.
### Admin panel and data table
The admin is **not** built from this design system. It runs on antd + Refine, and
`src/app/admin/admin.css` only re-skins antd to sit near the product palette
(warm floor, hairline borders, the same body font). Admin routes deliberately do
not load `globals.css` — that isolation keeps the 33 KB chat stylesheet off every
admin page — so nothing here inherits the tokens above.
- **Structure:** antd `Layout` with sider, header and content, wrapped in
`.admin-app-shell`; tables, cards, tags and statistics are antd components.
- **Own classes:** only `.admin-app-shell`, `.admin-loading` and `.admin-text-list`.
Anything else must be an `.ant-*` override inside `admin.css`.
- **Colors:** `admin.css` writes literal hex because it cannot see the tokens. Those
literals must stay in sync with the palette above by hand; this is the one
sanctioned exception to the no-raw-color rule.
- **Content width:** 1200px centred, set by antd layout, not by `.admin-scroll`.
- **Loading:** antd `<Spin>`, outside the waiting vocabulary in section 9.
## 6. Motion & Interaction
`--ease-out` is `cubic-bezier(.22, 1, .36, 1)` and is the only easing curve in the
system.
| Type | Duration | Easing | Usage |
|---|---:|---|---|
| Micro | 120ms | ease-out / `--ease-out` | Button, row, popover and tooltip feedback |
| Standard | 180ms | `--ease-out` | Dialogs, onboarding cards, auth steps |
| Message | 160ms | `--ease-out` | A message row entering the transcript |
| Thinking collapse | 180ms | `--ease-out` | The step timeline opening or closing in place (`grid-template-rows` 0fr ↔ 1fr) |
| Timeline label swap | 120ms | ease-out | “正在分析” → “已完成 N 步”, and a live row's label changing |
| Text reveal | per-frame release, not a duration | none | Streamed answer and thinking text; see Streaming states |
| Sheet | 280ms | `--ease-out` | Rectification sheet rising from the bottom |
| Spatial | Instant | None | Sidebar state changes and mobile drawer |
Looping animation is covered separately in section 9.
Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motion disables non-essential animation. Motion communicates state; decorative looping is limited to an active loading state.
## 7. Depth & Surface
Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals.
## 8. Agent wording and accuracy boundary
The Birth-Time Guide Agent controls wording only: it may choose a safe tone or extract a reviewable
draft, but it cannot select evidence domains, change `nextAction`, rank candidates, set confidence,
or grant save/apply permissions. Those decisions remain in the deterministic, versioned journey
state machine and its server-side scorer.
`confidence` is an internal, versioned safety gate for this product flow. It is not an external oracle,
clinical claim, or proof from calibrated real cases. Until matching external-engine parity and real-
case validation are complete, low and medium results never apply a minute, and high results still
require explicit confirmation of the matching representative time. Journey telemetry is limited to
metric name, phase, and optional confidence; it never records messages, dates, coordinates, case IDs,
or user IDs.
## 9. 等待与加载
等待按语义只分三类。同类必须共用同一套实现;不要为新入口再写一套旋转或呼吸。
| 类别 | 语义 | 组件 / 样式 | 时长 | `prefers-reduced-motion: reduce` |
| --- | --- | --- | ---: | --- |
| 整页 / 整块阻塞 | 首页揭幕前的两阶段载入(载入账户 → 准备对话)、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit` | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner``inline-spin` | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 报告生成等待态
个人报告要写几分钟,属于"等到有实质进度可报"的一类,因此在上表三类之外单独规定:**只有准备阶段用 `InlineSpinner`,进入写作阶段后换成分章进度,不再转圈。**
三个阶段跟随 worker 自己的 phase 阶梯(`queued` / `loading_context` / `generating_report``section:<id>``persisting_report`):
| 阶段 | 屏幕上 | 文案 |
| --- | --- | --- |
| 准备 | `InlineSpinner` | 正在准备你的星盘证据 |
| 写作 | 分章进度条 + 章节清单,无 spinner | 已完成 N / M 章 |
| 收尾 | `InlineSpinner` | 正在整理成文 |
硬规定:
- **进度条按章分格,一格一章,不画百分比。** job 的 percent 在 0→30 和 90→100 是瞬间跳变,只有中段跟随真实工作量;画成线性条等于演出后端没做的动作。格数恒等于章数,条与清单不可能互相矛盾。
- **不做时间插值,不显示预计剩余时间。** 界面上没有任何按定时器推进的东西;后端没动,屏幕就不动。章节耗时被重试放大到两倍以上,报不准比不报更伤。
- **正在写的那一章由行状态判定,不由 phase 名判定。** `section:<id>` 命名的是刚写完的那一章;且章节列表按 `section_id` 字典序返回,不是写作顺序,所以"已完成数 + 1"也会指错。唯一正确的判据是 `pending` 且已被认领。
- **停滞满 90 秒**`REPORT_PROGRESS_STALL_MS`)当前章文案改为「用时较长,仍在写」。不写第几次尝试——`attemptCount` 不出服务端。
- **写作失败的章仍然计入完成数**,进度条照常前进;走到头不等于全部成功,收尾按 `summarizePersonalReportFailure()` 的白名单文案说明。
- 可显示的只有章节名与章节状态(用户交付物的结构)。`attemptCount`、原始错误码、lease、job id、payload 一律不出服务端——与 BUG-043 的边界一致:那条禁止的是把后台评分状态渲染成用户要管理的面板,本处是只读等待屏上的交付物结构。
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 10. 生时校正常驻时间轴
校正是最长的流程,唯一能表达「正在收敛」的量是范围宽度。它此前只以范围变化句的形式散在消息流里,滚上去就看不见。时间轴把这个量变成常驻形态。
**它是一条只读的横条,钉在校正对话上方,回答「我在哪、还有多宽」;「选哪一分钟」仍然只由交付卡回答。**
### 三条硬规定
**一、它是 `.rectification-workspace__chat` 的第一个 grid 行,不是 `position: sticky`。** 该容器现在是三行:`var(--rectification-timeline-height) minmax(0, 1fr) auto`。条位于滚动容器 `.conversation` **之外**,因此 `scrollHeight` 不变,`use-conversation-scroll-anchor` 的贴底距离仍然精确,它的 `ResizeObserver`(只观察滚动容器的直接子元素)也永远看不到这条。**该 hook 不因本组件改动一行**,校正面不得出现第二套滚动跟随。若改成滚动容器内的 sticky 子元素,还会撞上 BUG-218/252 的老问题:容器留了 `padding-bottom` 时,sticky 子元素被内容盒夹住,永远贴不到可视底边。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px56px 等于既有的 `--rectification-jump-clearance`44px 触控尺寸 + `space-3`),不新造常数。
读数和轴的水平内边距在桌面对齐助手列:`space-8 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。**767px 以下只取 `space-4`,不加头像缩进**(BUG-918):条不在消息列里,手机上对齐头像列买不到任何可见收益,而那 44px 每侧要吃掉 375px 里的 88px,读数因此被裁。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。手机上读数四项、一行排完:
`05:0705:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题`
**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。
**一行是有宽度预算的,767px 以下必须按 375px 算,不是「应该放得下」。** BUG-918 的实证:13px 下四项 + 头像缩进已超 375px,第四项被自身 `text-overflow: ellipsis` 显示成「已…」。因此 compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)做三件事:`padding-inline` 去掉头像缩进、读数 `column-gap` 收到 `space-2``.rectification-timeline__dated` `display: none`。按逐字保守估宽,手机上四项 + 3×8px 间隙约 292px375px 可用 343px、390px 可用 358px320px 只余约 4px,靠条自身 `overflow: hidden` 兜底。**再往条上加一项,先重算这笔预算,不是直接追加一个 span。** 组件永远无条件渲染全部 span,隐藏只在 CSS 里发生,轴的 `aria-label` 仍由全部标签拼成,读屏软件不因视口宽度丢内容。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`
以下一律不得加回来——每一条都是被明确砍掉的:
| 不得出现 | 理由 |
| --- | --- |
| 范围变化句 | 服务端已写进落库助手消息,BUG-588 防复发要求它在那里;条上再写=同句同屏两次 |
| 收窄进度(「已从 N 分钟收到 M 分钟」) | 无服务端字段;前端相减会把放宽报成收窄。见上 |
| 「仍在范围内 N / 已排除 N」计数与图例 | 实心=在内、空心=已排除,答完第一题即自明;图例是过度提示的典型形态 |
| 阶段标签(时段 / 分钟) | `block_scan` / `minute` 是后台词汇,贴近 BUG-043 红线 |
| 「已对照 N 件经历」 | 仍归交付卡;卡上是人话「N 件里 M 件对得上」。它和条上的「已答 N 题」不是同一个量:后者是服务端 `inference_state.answered_probe_count`,问了几道答了几道,不是经历吻合数 |
| 「时间轴只读」注脚 | 在解释一个不存在的东西;没有元素看起来可点,就不需要声明不可点 |
| 预计剩余时间、定时器插值动画 | 与报告进度条同一条原则:不得演出后端没做的动作 |
**条上每个数字都必须是服务端投影里已有的字段。** 区间来自 `credible_range`,代表分钟来自上面那一次调用,已答题数来自 `answered_probe_count`;投影没给计数就整项不显示,不得补一个 `已答 0 题`
### 轴与标记
- **轴 = 当前搜索窗口(`candidate_range`),不是开场窗口。** 开场窗口不是定值(±15 / ±30 / ±60 / ±120 / 整天),且放宽会顶穿它(BUG-572:±15 → ±30 → ±60 → ±120)。窗口变化时轴重新对到新窗口,区间带与标记用 CSS transition 过渡到新位置(`prefers-reduced-motion` 由全局 `*` 规则收成一帧)。窗口不变时轴稳定,收窄照样读得出来。
- **候选标记一律等大,只分在范围内 / 已排除。** 不得按 `relativeSupport` / `probability_percent` 分级(尺寸、深浅、粗细都不行):BUG-560 状态是 blocked,候选间相对支持度 7–9 分(满分 100),校准显示分钟级几乎无区分力,分级显示等于用视觉编码放大统计上不显著的差异。被排除的点留在原地变空心、不消失——用户由此看见自己刚才那一答排掉了哪几分钟。
- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「24 小时」是事实而非占位。
- 跨午夜的窗口(`late_night` 是 23:00–03:59,末端早于始端)按次日展开,轴保持单调。
- 条上不得出现 `rectification-step-state` 类名(BUG-575 防复发)。
## 11. 区间交付卡
交付对象是区间里并排的候选分钟,不是支持度数字,也不是逐行空差异。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
每列 = 时间 + 相对可能性 + 至多三行 + 「更像这个」。列内不再有 `<h3>`。经历对照由服务端拼成一行:「8 件经历里 7 件对得上,最不合的是 2024 年 5 月那段感情」。未来窗一行:「下一个值得留意的时段:2027 年 3 月前后(事业)」;算过但没有窗写「未来一年没有明显的时段」;`by_time` 缺键写灰字「这一分钟还没对照」,不得回退成 `0 · 0 · 0` 或把「没算」说成「没有窗」。用户可见文案不得出现「强相关 / 有关联 / 弱关联」。
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。
**出生记录与范围冲突时换措辞,不换形状。** 医院记录的那一分钟落在目前范围外时(`record_conflict`),来源小字写满三层:默认仍按记录排盘、经历指向另一段时间差 N 分钟、两条路都可以走;每列按钮从「更像这个」改成「改用校正结果」,按钮下方补一行同为 `--type-caption` 的灰字「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」(`.rectification-range-delivery__switch-note`,与来源小字同字号同行高)。不新增按钮、不加确认弹窗、不改列数与间距;记录落在范围内或来源是「大概时间 / 时间段」时,卡片与今天完全一致。已采用的那一列不再显示这行小字。
引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。
## 12. 校正右栏盘面
右栏回答「当前按哪一分钟在看盘」,不回答「选哪一分钟」。顶部不加任何新说明文字。
**本命宫位改北印星盘。**`VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `<details>`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。
**折叠块只有两段(D102026-09-16)。** 此前盘面从上到下并排五个 `<details className="technique-audit">`,一屏扫下去全是折叠头,看不出哪个该点。现在收敛成两段,星盘本身不折叠:
| 段 | 装什么 |
| --- | --- |
| **候选分钟** · N 个时刻 | 「换升时刻」(段内组标题 + 逐分钟 LayerChips 列表)、「次级候选」子折叠(原「显示层」) |
| **参数与口径** | 「本命宫位表」、「唯一分钟确认门」、「本轮技法」、「两段本命上升」、「经历与大运对照」(含双轨一致性那句)、「交界节奏」 |
**这是换归属,不是删内容。** 六块一条不少,每块保留自己原来的渲染条件——确认门与技法表仍跟着 `table` 走,此前没有星盘时它们本来就不出现,现在也一样。所有引擎串仍逐一经过 `engineMeaningToDisplayCopy()`。逐分钟行的 `is-changed` / `is-working` / 「当前时间」 / 「已更新」标记一律不变。
两段都默认收起,且都是真 `<summary>`——嵌套多了一层,键盘可达性因此只能靠 `<summary>` 保证,不得把任何一块改成挂 `onClick``div`。盘面内 `.technique-audit` 的分隔线仍是全局关掉的,段内节奏由 `.rectification-board .technique-audit > *``margin-top` 给。
## 13. 回答的两层可见性
本命类聊天回答默认只展开口语层。从第一个 ATX H2(合同上是 `## 统一参数与原始结构`,切点按任意 H2,以便模型漂移时仍能折)起的全部章节装进 `<details className="answer-detail">`,默认收起。骨架仍完整生成、入库、可复制、可导出;折叠只改默认可见性。
- **口语层:** 第一个 H2 之前的全部内容,直接画在 `.message-markdown`。无 H2 的闲聊和 onboarding 打字机消息整篇留在这一层,不出现折叠块。
- **折叠层:** `<summary>` 主标「完整分析」;副标用 `<small>` 写章节名,取 `headings` 前三个(`统一参数与原始结构` 写成「统一参数」,`技法审计表` 写成「技法审计」),多于三个补「等 N 节」。不要只写「展开」。
- **技法审计:** 正文里已经有审计表时不另折一份;只有事件行、正文没有表时,`TechniqueAuditDisclosure` 跟在折叠块内部。没有 H2 时仍留在回答底部原位。
- **流式:** 折叠块保持收起,不渲染 children,副标写「正在写…」。不得出现 spinner / 骨架 / 「正在加载」。流式结束后再解析报告 markdown。口语层在流式期间仍走 `StreamingMarkdown`
- **个人报告页**走 `personal-report-document-view.tsx`,不经过本组件。
- **窄屏表格:** 三列表(技法审计)继续卡片化。四列及以上(Yoga、统一参数)在 `.conversation` 里横向滚动,右缘用 canvas 渐隐提示还可滑;`.conversation` 自身 `overflow-x: hidden`,页面不得出现横向滚动条。个人报告表格不走这套规则。
- **表单输入 16px** 全局 `input` / `select``--type-body-md`16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px`.composer textarea` 仍是 16px。
## 14. 断点
宽度切点只允许出现在 `globals.css` 顶部注释的白名单里。壳层切点以 `sidebarViewportForWidth` 为准:小于 768 移动,小于 1024 平板,否则桌面。组件级栅格(会员方案卡、校正候选)用 640/641。校正独立问题块(`.rectification-message-question.is-standalone`)与助手正文共用 `--assistant-content-inset`。报告目录在 860 塌成抽屉,因为那是第三栏的内容宽度,不是设备宽度。
禁止再引入 900 / 720 / 760 / 639 / 620 / 430 这类与壳层或彼此错位的切点。新切点必须先改白名单和 `viewport-breakpoint-contract.test.ts`
## 15. 只读星盘页
独立 route `/chart`,不进 `page.tsx`。外壳先画、主盘先到、其余 tab 按需。不扣点、不调模型。盘面从现有路径复用 `VedicChartSvg`,不搬家。
`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`)、大运 Chara`/api/dasha/chara`)、西洋(`/api/western`)、七政(`/api/qizheng`)在用户点到对应 tab / 非 D1 chip 时才请求。未点的 tab 不发请求。
### 结构
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。
五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。五个等重的药丸按钮会读成五个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。chip 比 Tab **低一级**:无描边、无卡面、caption 字号、三级文字色,当前项只有一块浅底。它是一个 Tab 内部的筛选器,不是第二层导航。Tab 与 chip 的触摸目标仍是 44px。
侧栏在「新建对话」之后、「我的报告」之前加「星盘」「星历」两个入口;折叠轨(64px)仍用既有 `report-nav-button` 图标行,当前项用白玻璃面 + 2px 深棕标记,不新造选中态。星历入口指向 `/ephemeris`(由另一单交付,合入前 404 是预期)。
### 排盘参数表
参数是**一张键值表**`.chart-page-params-table`),八行:出生时间(含校正状态)、出生地(含经纬)、岁差、交点、上升、月宿、当前大运、引擎。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开。
**不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、分盘 chip 均 44px。
### 行星表
三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`,表本身 `min-width: 26rem`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。
### 三套坐标系
每一套必须在自己的 Tab 里写明自己是什么,并显式点名不能互相换算、也不能叠成「双重印证」:
- 印度盘:恒星黄道,岁差用资料里选的那一套。
- 西洋盘:回归黄道,宫位 Placidus;右栏第一张是边界卡。端点未上线时 Tab 仍在,渲染静态说明,整页不失败。
- 七政四余:宿度自角宿初度起算。必须同时出现计都派别与庙旺未闭合。端点未上线时同样只渲染静态说明。
「传统象征」是词条,不得写成运势。页面上不另写「词条式释义,不是对你个人的判断」——行星卡本身已经是度数、星宿、D9,没有判断句。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dashakn_rao 变体)**,不要写成 Narayana。
### 等待态
星盘页是独立文档。外壳揭幕(标题、返回)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。
| 时刻 | 文案 |
| --- | --- |
| 外壳已出、主盘未到 | 这一张盘还没拿到。 |
| 已点大运 / 西洋 / 七政,该路还没返回 | 这一栏还没拿到。 |
| 已点非 D1 分盘,分盘包还没返回 | 这一分盘还没拿到。 |
| 引擎 429 | 算盘的服务正忙,稍等几秒再打开就好。 |
| 其它主盘失败 | 这张盘算不出来,我们已经记录下来了。 |
## 16. 星历页
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。
- **日期条:** 居中的 ` 日期 `——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。