feat(ui): 标题不再落宋体,强调色分阶换 coral,删输入框常驻底栏(BUG-737/738)
Independent Staging Quality Gate / validate (push) Failing after 9m26s
Independent Staging Quality Gate / publish (push) Skipped

BUG-737:--font-display 以两个从未加载的 Anthropic 授权字体开头
(Tiempos Headline / StyreneB:无 @font-face、public/ 无字体、layout.tsx
只 vendor 了 Inter),于是每个中文标题都掉到 Songti SC / SimSun——20 条
规则宽,含助手回答正文的 h2/h3。display 与 body 合并为同一条无衬线栈,
层级改由字重承担(33 条 display 规则 400→500)。

拉丁衬线方案实测否决:Newsreader 拉丁子集 132 KB(Inter 的 2.7 倍),
只为给一个品牌字上衬线,且会把「D10 事业盘怎么读」劈成两种字形。

BUG-738:亮色 #85432f 与暗色 #d78064 不同源;--color-ring 在
@theme inline 里硬编码不跟随 :root。产品拍板换 Claude coral #cc785c,
但实测它作文字色只有 3.14:1,而 62 个调用点里 53 个是 color:。
按角色拆两阶:--color-action #a9583e(文字 4.85:1)、
--color-action-strong #cc785c(填充/导轨/焦点环,3:1 非文字阈值)。
--report-accent 刻意保持 #85432f(报告是纸面,不跟应用强调色)。

T1.3/T1.4:模型选择器进 ChatComposer 的 toolbar 插槽,删掉常驻 44px 的
.composer-footer;顶栏 68→46px,「分析对象:」改为标题旁静默 chip。

防复发:新增 font-stack-loadable-contract,断言字体栈里每个 family 要么
vendored 要么是系统字体,且 --font-display 不得触达任何 CJK 衬线。

测试 3346→3350,fail 仍 31 且清单与基线逐条一致;/ 仍 Static;
CSS gzip 41,095→41,096(+0.002%);快速门 pytest 段 798 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 04:53:14 +00:00
co-authored by Claude Opus 5
parent 3fd74daf34
commit 66d9f59d84
22 changed files with 580 additions and 159 deletions
+48 -18
View File
@@ -23,9 +23,10 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
| 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 |
| 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 |
@@ -33,7 +34,7 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
| 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 |
| 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
@@ -44,24 +45,39 @@ outside `.personal-report-*` and `.report-center-*` may use them.
|---|---|---:|---|
| 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 |
| 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
Scarce is a budget, not zero. The sidebar spends it in exactly three places, and
a fourth needs a reason:
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`). This is the sidebar only —
the settings dialog's pane menu carries no accent bar and separates hover from
current with surface and ink rank instead.
3. The profile initial's circle (`--color-action`), which only renders when the
account has no uploaded avatar.
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
@@ -69,6 +85,11 @@ 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`.
@@ -97,8 +118,8 @@ identical.
| 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. |
| 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. |
@@ -137,8 +158,10 @@ is read through an external store so a change in one tab reaches the others.
### 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 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. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- 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.
@@ -193,11 +216,12 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Input and composer
- **Structure:** warm canvas field, hairline, typed value, optional icon action.
- **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), invalid. Generating does not disable the textarea; the send control becomes stop.
- **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`.
- **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
@@ -291,6 +315,12 @@ The birth-time rectification session is the consultation transcript plus a house
- **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. 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.
### Navigation item
- **Structure:** title, optional subtitle for another person's chart, current-state marker.