diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6075b600..bead7195 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6719,3 +6719,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-439 | 回答里的围栏代码块没有任何样式,长代码行撑破消息栏 + +- 状态:resolved +- 首次发现:2026-08-29 +- 最近更新:2026-08-29 +- 影响面:`/` 主对话中 Agent 回答里的 ``` 围栏代码块 +- 用户现象:代码块没有容器,行内代码的小胶囊底色套在整块代码上;一行长代码不换行也不滚动,直接溢出消息栏,移动端会把版面顶横。 +- 触发条件:回答里出现围栏代码块。 +- 根因:`globals.css` 只有 `.message-markdown code`(行内胶囊),**没有任何 `pre` 规则**。react-markdown 输出的裸 `
` 保留浏览器默认的 `white-space: pre` 且无 `overflow`,内容宽度不受 `.message-content` 的 `max-width` 约束。`.message-content` 上的 `min-width: 0` 只能让 flex 收缩,挡不住块内溢出。
+- 修复:新增 `MarkdownCodeBlock` 组件接管 `pre`,输出「语言标签 + 复制按钮」的头条和可横向滚动的代码区;`.markdown-code pre > code` 设 `overflow-x: auto` 并重置行内胶囊底色,容器 `overflow: hidden` 收住圆角。未引入语法高亮依赖。
+- 验证:`frontend/tests/markdown-code-block-contract.test.ts` 锁 `overflow-x: auto` 与 `white-space: pre` 必须同时存在、胶囊底色必须重置、`pre` 必须走该组件、复制按钮有 aria-label、剪贴板被拒时不得误显示「已复制」、卸载时清定时器。
+- 防复发:给 markdown 新增块级元素时必须同时给出容器与溢出策略;行内与块级代码的样式不得共用一条规则。
+- 相关记录:BUG-434
+- 复发自:无
+- 修复版本:待发布
+
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index d1ac064e..549a3a84 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -16,12 +16,13 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
 | 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 and mobile navigation |
+| 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` | `#676762` | Supporting copy and labels |
-| Tertiary text | `--color-ink-tertiary` | `#8a8983` | Hints and metadata |
+| 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 |
@@ -34,15 +35,97 @@ Jyotisha feels like a private reading room: warm, editorial, grounded, and quiet
 | Error | `--color-danger` | `#9a2f2f` | Errors and destructive actions |
 | Accessible focus | `--color-focus` | `#85432f` | Keyboard focus and input focus |
 
+The personal report carries a second, narrower palette for its printed-paper
+surface. It is deliberately separate — the report is a document, not app chrome —
+and nothing outside `.personal-report-*` may use it.
+
+| Role | Token | Value | Usage |
+|---|---|---:|---|
+| Report paper | `--report-paper` | `#f8f5ee` | The report sheet itself |
+| Report rule | `--report-rule` | `#c9c2b7` | Rules and dividers inside the sheet |
+| Report accent | `--report-accent` | `#85432f` | Report headings, same hue as the action color |
+
 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:
+
+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`).
+3. 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` | `#d4785a` | 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: ink, action, danger, success and warning must
+all clear 4.5:1 against the dark reading surface, and the four neutral surfaces
+must increase in lightness in floor → canvas → muted → strong order.
+
+**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.
+
+**Not built yet:** a visible light/dark/system control. The `data-theme` hook
+exists so a toggle can be added without touching any of the styling above.
+
 ## 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 with `next/font/google` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
-- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`.
+- 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
 
@@ -68,7 +151,8 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
 - Chat reading width: 760px for the welcome/composer and 900px for long answers.
 - Admin content width: 1200px, centered.
 - Desktop shell: 288px sidebar plus flexible reading panel.
-- Breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above.
+- 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.
 
 ## 5. Components
@@ -218,18 +302,36 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
 
 ### Admin panel and data table
 
-- **Structure:** editorial header, cream form panels, and a light data table separated by warm hairlines.
-- **States:** loading, empty, populated, generated codes, error, copy success.
-- **Responsive:** form collapses to two columns then one; table scrolls horizontally.
+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 ``, 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 | Button and row feedback |
-| Standard | 180ms | `cubic-bezier(.22, 1, .36, 1)` | Sheet and message entry |
+| 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 |
+| Sheet | 280ms | `--ease-out` | Rectification sheet rising from the bottom |
 | Spatial | Instant | None | Sidebar state changes and mobile drawer |
-| Emphasis | 360ms | `cubic-bezier(.16, 1, .3, 1)` | Loading mark only |
+
+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.
 
diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx
index 2da5501b..ba5a4540 100644
--- a/frontend/src/app/error.tsx
+++ b/frontend/src/app/error.tsx
@@ -1,17 +1,22 @@
 "use client";
 
+// These pages sit in the shared root layout segment and must not import the
+// site stylesheet — that would drag the chat styles onto every admin route — so
+// they cannot read a single design token. They restate the handful they need,
+// in both themes, and those values are kept in step with the palette by hand.
+
 import { useEffect } from "react";
 
 import { TriangleAlert } from "lucide-react";
 import Link from "next/link";
 
-const canvas = "var(--color-canvas, #fbfaf7)";
-const ink = "var(--color-ink, #1d1d1f)";
-const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
-const danger = "var(--color-danger, #9a2f2f)";
-const border = "var(--color-border, #d8d6cf)";
-const action = "var(--color-action, #85432f)";
-const focus = "var(--color-focus, #85432f)";
+const canvas = "var(--bp-canvas)";
+const ink = "var(--bp-ink)";
+const inkSecondary = "var(--bp-ink-soft)";
+const danger = "var(--bp-danger)";
+const border = "var(--bp-border)";
+const action = "var(--bp-action)";
+const focus = "var(--bp-action)";
 
 const actionStyle = {
   alignItems: "center",
@@ -55,6 +60,35 @@ export default function RootError({
       }}
     >