The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result. Co-authored-by: Cursor <cursoragent@cursor.com>
46 KiB
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 | --color-action |
#85432f |
High-signal links, rings, and compact actions |
| Primary soft | --color-action-soft |
#f4e8e2 |
Editorial emphasis without a dark block |
| Primary active | --color-action-hover |
#6f3627 |
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 |
#85432f |
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 |
Same as --color-action; scarce evidence links |
Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of the interface remains light. Dark ink is punctuation, never a page-scale surface. No raw color may appear in UI styles outside these tokens and their documented alpha mixes.
Where the action color appears
Scarce is a budget, not zero. The sidebar spends it in exactly three places, and a fourth needs a reason:
新建对话— 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.- The 2px bar on the active session (
--sidebar-ring). - The profile initial's circle (
--color-action), 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 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 | #85432f |
#d78064 |
The clay hue is kept and lifted. #85432f is 2.1:1 on #262624 — unreadable. |
| Action hover | #6f3627 |
#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 printkeeps white paper.- The admin is antd + Refine and stays light. Re-skinning
admin.cssalone would give a dark shell around light antd components, which reads as broken; doing it properly means switching antd totheme.darkAlgorithmas 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:
"Tiempos Headline", "Songti SC", "STSong", "Noto Serif CJK SC", Georgia, serif. The licensed Copernicus/Tiempos files are unavailable; the Chinese Song serif stack is the declared production substitute. - Body/UI:
StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif. Inter is loaded withnext/font/localfromsrc/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. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute. - 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 importglobals.css— importing it would drag the chat stylesheet onto every admin route. They therefore cannot read--font-monoor any token, and inline their own values: a system stack for body text andui-monospace, SFMono-Regular, Menlo, monospacefor 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.
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.
- Component breakpoints are separate and narrower, each owned by one surface rather than the shell: 900px and 480px (redemption and profile forms), 760px and 720px (personal report and report centre), 640px (rectification candidates), 639px (membership header), 620px (onboarding cards), 430px (toasts and the conversational rectification panel). Do not repurpose one for another surface, and do not assume testing the three layout tiers covers them.
- All full-height surfaces use
100dvh. Touch targets are at least 44px. Where adjacent controls sit closer than 44px, the hit expands to the largest size that does not overlap; those exceptions are locked byfrontend/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 membership use
@/components/ui/button. Login, onboarding, and birth-time dialogs still use.button-primary/.button-secondary, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growingpage.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: warm canvas field, hairline, typed value, optional icon action.
- States: default, hover, focus with deep-brown ring, disabled, invalid, loading.
- Accessibility: persistent label where practical; composer has an explicit accessible label.
- One composer: the rectification surface renders the same
ChatComposeras the main chat; there is no second composer. A surface that owns its own draft passes it asvalue; the main chat reads the draft store. Both count down from the same 500-character ceiling throughCharacterRemaining.
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. - 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 sameCheckas the theme panel. - Values:
raman(default) /lahiri/kp/true_pushya. Copy lives infrontend/src/lib/ayanamsa.ts. - Hint: “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
- Placement: after
ProfileFieldson the self-profile form only. Not on other-chart save, not onpage.tsx. - States: default, hover, selected (
aria-checkedonly), 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_MSisBOOTSTRAP_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 | disabled, stop visible |
empty |
“这段校正还没有开始。” and one primary action “开始提问” | enabled |
question-live |
the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
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 |
verified_idle |
one closing line postAdoptVerifyDone after adopted reverse-verify is exhausted; 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 |
disabled, stop visible |
candidates |
the offer cards under the message that offered them | enabled |
adopting |
“正在采用 HH:MM…” through the follow-up turn | disabled during the chain |
confirmed |
“已确认校正时间:HH:MM” | enabled |
readonly |
“该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled |
- Rules: a follow-up turn continues on the live row already in place;
busynever 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 reload 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 reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; 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. - Accessibility: the sidebar opening note sets
aria-busy; the live row is the timeline row (role="status"shimmer label); the reload and start actions are real 44px buttons.
Birth time intake
- Structure: birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source.
- Surface: choice rows use the warm canvas and hairline system; the selected row uses
--color-action-softwith 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. 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, confidence, and active-time status visually separate. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute 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
nextActionand 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 and keeps skip and pause as secondary 44px actions. - 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_pendingis a quiet progress surface with cancellable bounded polling and no manual compare control.retry_scoringpreserves 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-DDprofile 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.
Navigation item
- Structure: title, optional metadata, 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 “收藏对话 / 历史对话”.
- 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, reports action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons.
- Expanded content: new chat, my reports, chart list, favorites, then history. Nested lists indent under their section labels. Actions, section labels, and session rows share 18px icons, caption/body type, and ink/muted tokens. Empty untitled sessions stay off the history list. Pin is favorite; archive stays in the session menu, not as a history-header toggle.
- Scroll ownership: header and footer remain fixed;
SidebarContentis the sole sidebar scroll owner. - Scrollbar:
SidebarContent, ordinary session.conversation, and the rectification house board use a quiet overlay scrollbar: transparent track, noscrollbar-gutter, and a 4px warm thumb mixed from--color-ink. The thumb stays transparent until hover or keyboard focus inside the scroller, then usescolor-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: session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence.
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: every assistant message carries the 32px Jyotisha logo avatar; user messages stay visually lighter and avatar-free.
- 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.tsxat 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 |
failed |
run.failed, abort, 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.
Suggestion card
- Structure: topic label, question, directional icon. Categories are not numbered because they have no required order. At tablet widths, the cards stack into one column so Chinese questions keep natural phrase boundaries beside the persistent sidebar.
- Surface: warm light cards; the lead card uses the pale brown emphasis surface and border instead of a dark block.
- States: default, hover, active, focus, disabled, loading, fallback notice.
- Visibility: the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message.
Personal report centre
- Structure: full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards.
- Surface:
--color-canvas-softfloor; cards use--color-canvas, a warm hairline, and--radius-lg. No drop shadow. Status is a caption badge, not a colored block. - Typography: page title uses
--type-display-lg/--font-displayat weight 400 withtext-wrap: balance. Card titles are--type-title-mdat weight 500. Body stays--type-body-mdor--type-body-sm. - Width: 900px centered, matching long-form chat reading. Cards stack below 720px.
- Actions: “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- States: loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
- Accessibility: back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
Personal report reader
- Structure: sticky screen chrome (back, print), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance.
- Surface: page floor
--color-canvas-soft; the document is a--color-canvassheet with a hairline and--radius-lg. Print flattens the sheet, hides chrome, and pins the light palette. - Typography: the subject name uses
--type-display-lgserif at weight 400. Section titles use--type-display-smserif at weight 400. Theme headings are--type-title-mdsans at weight 500. Narrative is--type-body-mdat 1.65, the same measure as chat answers. Labels stay 12–13px. - Accent: claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge.
- Charts: North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex.
- Width: 900px for the document; chart and evidence columns stack at 760px.
- States: loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses
InlineSpinner. - Accessibility: back, print, and appendix disclosure are 44px. Generating copy uses
role="status". Print remains keyboard-initiated from the chrome button.
Product entrypoint card
- Structure: the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”.
- Copy: chat history titles daily readings as “8月22日 · 今日节奏”, topic cards as “事业 · …”, and rectification as “生时校正 · 8月21日”. Clicking the daily card 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.
- States: default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback.
- Responsive: cards stack below 900px without introducing a large nested button. Stacked cards hug their copy instead of stretching to a min-height; the footer stacks supporting copy above a leading-aligned one-line action.
- Accessibility: each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target.
Start greeting
- Content: invite the user to ask what matters now; do not repeat that birth data is ready or explain setup state.
- Timing: use the browser's local hour: morning 05:00–10:59, noon 11:00–13:59, afternoon 14:00–17:59, evening 18:00–22:59, and late night 23:00–04:59.
- Variation: each time band has three concise prompts; select one once per visit so re-renders do not change the sentence.
Account popover
- Structure: identity header, profile action, redeem action with balance, administrator-only code-management link, divider, and logout action.
- 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, administrator, and logout routing.
- Accessibility:
aria-expanded,aria-controls, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return.
Profile dialog
- Structure: profile title, existing profile fields, inline result, and save action.
- Width: 560px desktop maximum with viewport-safe spacing and internal scrolling.
- States: open, invalid, saving, success, and error.
Redeem dialog
- Structure: current balance, redemption-code form, and inline result.
- Width: 420px desktop maximum.
- States: open, submitting, success, and error.
Membership
- Structure: full-page archive on the page floor. Header chrome is back, orders, and redeem; the hero names the current plan; tabs switch membership vs credits; each plan/credit card carries one purchase action.
- Actions: header chrome stays hairline
--color-ink-secondary. The one filled terracotta control is the recommended-plan purchase (Buttondefault). Other plan and credit buys useButtonoutline. Redeem opens a dialog; it is not a second filled button on the same screen.
Logout dialog
- Structure: confirmation title and explanation, cancel action, and destructive confirm action.
- 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
Layoutwith sider, header and content, wrapped in.admin-app-shell; tables, cards, tags and statistics are antd components. - Own classes: only
.admin-app-shell,.admin-loadingand.admin-text-list. Anything else must be an.ant-*override insideadmin.css. - Colors:
admin.csswrites 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, the starter theme card's focus state 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 行)。采用卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出采用卡。采用后前事核对结束走 verified_idle:一行收尾文案,没有 live 行、没有重载。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(BOOTSTRAP_PREPARE_TIMEOUT_MS)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 aria-busy),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 sr-only 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(SESSION_PREFETCH_COUNT)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
Admin 的 antd <Spin> 是独立设计系统,不在此表。