feat(frontend): add a warm dark theme and close the DESIGN.md drift
The palette is restated for dark rather than inverted: elevation reads through lightness on dark and through darkness on light, so the floor is the darkest surface here and the second-lightest there. The clay hue is kept and lifted, because #85432f is 2.1:1 on a dark ground. All 37 themeable tokens are covered, in an OS-preference block and a data-theme block that a contract test keeps identical, and ink, action, danger, success and warning are asserted at 4.5:1 against the dark canvas. Four raw colors that would have stayed light-theme values are tokenised (the avatar hairline, the sheen sweep, a one-off shadow, a literal warning hex). The QR keeps literal white in both themes, since scanners need light modules to be light, and print keeps white paper. The four root boundary pages cannot read a token, so they restate the handful they need in both themes. forbidden.tsx also stops painting a bespoke near-black page in four colours that appear nowhere in the palette, which broke the rule that dark ink is never a page-scale surface. Also fixes what the audit found in DESIGN.md itself: two ink values that had drifted from the code, a motion tier documented at 360ms that was never implemented, a breakpoint section claiming three tiers where the stylesheet has eleven, an undocumented report-paper palette, and an admin section describing a bespoke panel that antd + Refine replaced. Five zero-reference admin rules go with it. The sidebar gets the accent, opaque drawer, heading rank and empty-state guidance settled earlier, and fenced code blocks finally get a container. BUG-439. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
@@ -6719,3 +6719,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-439 | 回答里的围栏代码块没有任何样式,长代码行撑破消息栏
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-29
|
||||
- 最近更新:2026-08-29
|
||||
- 影响面:`/` 主对话中 Agent 回答里的 ``` 围栏代码块
|
||||
- 用户现象:代码块没有容器,行内代码的小胶囊底色套在整块代码上;一行长代码不换行也不滚动,直接溢出消息栏,移动端会把版面顶横。
|
||||
- 触发条件:回答里出现围栏代码块。
|
||||
- 根因:`globals.css` 只有 `.message-markdown code`(行内胶囊),**没有任何 `pre` 规则**。react-markdown 输出的裸 `<pre>` 保留浏览器默认的 `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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
+113
-11
@@ -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 `<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 | 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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bp-canvas: #fbfaf7;
|
||||
--bp-ink: #1d1d1f;
|
||||
--bp-ink-soft: #5f5f59;
|
||||
--bp-border: #d8d6cf;
|
||||
--bp-action: #85432f;
|
||||
--bp-danger: #9a2f2f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
.root-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
|
||||
`}</style>
|
||||
<TriangleAlert aria-hidden="true" color={danger} size={32} />
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
// Sits in the shared root layout segment and must not import the site
|
||||
// stylesheet — that would drag the chat styles onto every admin route — so it
|
||||
// restates the few tokens it needs, in both themes, kept in step by hand.
|
||||
// It used to paint a bespoke near-black page in colours that appear nowhere in
|
||||
// the palette, which broke the rule that dark ink is punctuation and never a
|
||||
// page-scale surface.
|
||||
const canvas = "var(--bp-canvas)";
|
||||
const ink = "var(--bp-ink)";
|
||||
const inkSecondary = "var(--bp-ink-soft)";
|
||||
const action = "var(--bp-action)";
|
||||
|
||||
export default function Forbidden() {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: "#11100e",
|
||||
color: "#f2eee6",
|
||||
background: canvas,
|
||||
color: ink,
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bp-canvas: #fbfaf7;
|
||||
--bp-ink: #1d1d1f;
|
||||
--bp-ink-soft: #5f5f59;
|
||||
--bp-action: #85432f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-action: #d4785a;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-action: #d4785a;
|
||||
}
|
||||
`}</style>
|
||||
<section style={{ maxWidth: "32rem", textAlign: "center" }}>
|
||||
<p style={{ color: "#c8a96b", letterSpacing: "0.12em" }}>403</p>
|
||||
<h1>无权访问后台</h1>
|
||||
<p style={{ color: "#b8b1a5", lineHeight: 1.7 }}>
|
||||
<p style={{ color: action, fontSize: "14px", fontWeight: 500, letterSpacing: "0.12em", margin: 0 }}>403</p>
|
||||
<h1 style={{ color: ink, fontSize: "20px", fontWeight: 600, margin: "12px 0 0" }}>无权访问后台</h1>
|
||||
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: "12px 0 0" }}>
|
||||
当前账号已登录,但没有后台访问权限。请联系管理员核对角色授权。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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",
|
||||
@@ -44,6 +44,35 @@ export default function GlobalError({
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bp-canvas: #fbfaf7;
|
||||
--bp-ink: #1d1d1f;
|
||||
--bp-ink-soft: #5f5f59;
|
||||
--bp-border: #d8d6cf;
|
||||
--bp-action: #85432f;
|
||||
--bp-danger: #9a2f2f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
|
||||
`}</style>
|
||||
<main style={{ maxWidth: "32rem", textAlign: "center" }}>
|
||||
|
||||
+191
-29
@@ -119,6 +119,9 @@
|
||||
--space-24: 96px;
|
||||
--composer-reserve: 148px;
|
||||
--ease-out: cubic-bezier(.22, 1, .36, 1);
|
||||
/* Hairlines and sheens that are dark-on-light here and light-on-dark there. */
|
||||
--ring-hairline: oklch(0 0 0 / .1);
|
||||
--sheen: rgba(255, 255, 255, .54);
|
||||
--sidebar-background: var(--color-sidebar);
|
||||
--sidebar-solid: var(--color-sidebar-solid);
|
||||
--sidebar-foreground: var(--color-ink);
|
||||
@@ -135,6 +138,153 @@
|
||||
--sidebar-width-mobile: min(86vw, 320px);
|
||||
}
|
||||
|
||||
/* ── Dark theme ──────────────────────────────────────────────────────────────
|
||||
Three states: no attribute follows the OS; data-theme pins a choice. The two
|
||||
blocks below MUST declare the same token set — class-name-definition-contract
|
||||
has a sibling test that fails if they drift apart. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Warm dark, never a neutral black: the light theme's parchment reads warm, and
|
||||
dropping to a cool grey would change the product's identity, not just its
|
||||
brightness. Elevation reads through lightness — floor is darkest, raised
|
||||
surfaces step up — which is the inverse of the light theme, where raised
|
||||
surfaces step down. Roles are preserved, values are not simply inverted. */
|
||||
--color-canvas-soft: #1a1a19;
|
||||
--color-canvas: #262624;
|
||||
--color-canvas-muted: #30302d;
|
||||
--color-canvas-strong: #3b3a37;
|
||||
|
||||
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
|
||||
a dark ground. Hover now lightens instead of darkening. */
|
||||
--color-action: #d4785a;
|
||||
--color-action-soft: #3a2620;
|
||||
--color-action-hover: #e59273;
|
||||
--color-focus: #d4785a;
|
||||
--color-action-on-dark: #8f4a33;
|
||||
|
||||
--color-ink: #f2f0ea;
|
||||
--color-ink-strong: #e2dfd6;
|
||||
--color-ink-secondary: #b3afa4;
|
||||
--color-ink-tertiary: #928e84;
|
||||
|
||||
--color-sidebar: rgba(31, 30, 29, .86);
|
||||
--color-sidebar-solid: #1f1e1d;
|
||||
--color-selected: rgba(255, 255, 255, .07);
|
||||
--color-light-hover: rgba(255, 255, 255, .06);
|
||||
--color-frosted: rgba(38, 38, 36, .86);
|
||||
|
||||
/* Punctuation inverts: a dark block on light becomes a light block on dark, and
|
||||
the text riding the lifted clay button has to go dark to stay legible. */
|
||||
--color-surface-dark: #f2f0ea;
|
||||
--color-surface-dark-raised: #e2dfd6;
|
||||
--color-surface-dark-soft: #d3d0c7;
|
||||
--color-on-dark: #241f1c;
|
||||
--color-dark-hover: rgba(29, 29, 31, .1);
|
||||
--color-dark-muted: #4a4842;
|
||||
--color-dark-border: rgba(29, 29, 31, .18);
|
||||
|
||||
--color-border: #3c3b37;
|
||||
--color-border-strong: #56544e;
|
||||
|
||||
/* Semantic hues are lifted for contrast on a dark ground; the muted pairs stop
|
||||
being pale washes and become dark tints of the same hue. */
|
||||
--color-danger: #e08573;
|
||||
--color-success: #74b189;
|
||||
--color-warning: #d8a758;
|
||||
--color-danger-muted: #3a2523;
|
||||
--color-success-muted: #22301f;
|
||||
|
||||
/* A scrim has to darken a surface that is already dark, and shadows need real
|
||||
opacity to register without a light ground to fall on. */
|
||||
--color-scrim: rgba(0, 0, 0, .58);
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 20px -18px rgba(0, 0, 0, .6);
|
||||
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, .35), 0 12px 28px -16px rgba(0, 0, 0, .55);
|
||||
|
||||
/* The report is a document, so its paper follows the theme rather than staying
|
||||
a lit sheet in a dark room. */
|
||||
--report-paper: #262624;
|
||||
--report-rule: #3c3b37;
|
||||
--report-accent: #d4785a;
|
||||
|
||||
/* Hairlines that were dark-on-light have to become light-on-dark. */
|
||||
--ring-hairline: oklch(1 0 0 / .14);
|
||||
--sheen: rgba(255, 255, 255, .16);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Warm dark, never a neutral black: the light theme's parchment reads warm, and
|
||||
dropping to a cool grey would change the product's identity, not just its
|
||||
brightness. Elevation reads through lightness — floor is darkest, raised
|
||||
surfaces step up — which is the inverse of the light theme, where raised
|
||||
surfaces step down. Roles are preserved, values are not simply inverted. */
|
||||
--color-canvas-soft: #1a1a19;
|
||||
--color-canvas: #262624;
|
||||
--color-canvas-muted: #30302d;
|
||||
--color-canvas-strong: #3b3a37;
|
||||
|
||||
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
|
||||
a dark ground. Hover now lightens instead of darkening. */
|
||||
--color-action: #d4785a;
|
||||
--color-action-soft: #3a2620;
|
||||
--color-action-hover: #e59273;
|
||||
--color-focus: #d4785a;
|
||||
--color-action-on-dark: #8f4a33;
|
||||
|
||||
--color-ink: #f2f0ea;
|
||||
--color-ink-strong: #e2dfd6;
|
||||
--color-ink-secondary: #b3afa4;
|
||||
--color-ink-tertiary: #928e84;
|
||||
|
||||
--color-sidebar: rgba(31, 30, 29, .86);
|
||||
--color-sidebar-solid: #1f1e1d;
|
||||
--color-selected: rgba(255, 255, 255, .07);
|
||||
--color-light-hover: rgba(255, 255, 255, .06);
|
||||
--color-frosted: rgba(38, 38, 36, .86);
|
||||
|
||||
/* Punctuation inverts: a dark block on light becomes a light block on dark, and
|
||||
the text riding the lifted clay button has to go dark to stay legible. */
|
||||
--color-surface-dark: #f2f0ea;
|
||||
--color-surface-dark-raised: #e2dfd6;
|
||||
--color-surface-dark-soft: #d3d0c7;
|
||||
--color-on-dark: #241f1c;
|
||||
--color-dark-hover: rgba(29, 29, 31, .1);
|
||||
--color-dark-muted: #4a4842;
|
||||
--color-dark-border: rgba(29, 29, 31, .18);
|
||||
|
||||
--color-border: #3c3b37;
|
||||
--color-border-strong: #56544e;
|
||||
|
||||
/* Semantic hues are lifted for contrast on a dark ground; the muted pairs stop
|
||||
being pale washes and become dark tints of the same hue. */
|
||||
--color-danger: #e08573;
|
||||
--color-success: #74b189;
|
||||
--color-warning: #d8a758;
|
||||
--color-danger-muted: #3a2523;
|
||||
--color-success-muted: #22301f;
|
||||
|
||||
/* A scrim has to darken a surface that is already dark, and shadows need real
|
||||
opacity to register without a light ground to fall on. */
|
||||
--color-scrim: rgba(0, 0, 0, .58);
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 20px -18px rgba(0, 0, 0, .6);
|
||||
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, .35), 0 12px 28px -16px rgba(0, 0, 0, .55);
|
||||
|
||||
/* The report is a document, so its paper follows the theme rather than staying
|
||||
a lit sheet in a dark room. */
|
||||
--report-paper: #262624;
|
||||
--report-rule: #3c3b37;
|
||||
--report-accent: #d4785a;
|
||||
|
||||
/* Hairlines that were dark-on-light have to become light-on-dark. */
|
||||
--ring-hairline: oklch(1 0 0 / .14);
|
||||
--sheen: rgba(255, 255, 255, .16);
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.phrase-nowrap { white-space: nowrap; }
|
||||
@@ -158,7 +308,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.credit-button span { display: inline-flex; align-items: center; margin: 0; color: inherit; font-size: inherit; line-height: 1; }
|
||||
.credit-button .credit-icon { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.welcome-mark { margin-bottom: 20px; }
|
||||
.welcome h1, .auth-panel h1, .admin-header h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; }
|
||||
.welcome h1, .auth-panel h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; }
|
||||
.welcome-copy { max-width: 520px; margin: 13px 0 28px; color: var(--color-ink-secondary); font-size: 15px; line-height: 1.65; }
|
||||
.onboarding-card-reveal { min-width: 0; max-width: 100%; animation: onboarding-card-enter 180ms var(--ease-out) both; }
|
||||
.onboarding-card-reveal-inner { min-width: 0; max-width: 100%; min-height: 0; }
|
||||
@@ -383,8 +533,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.code-form label { display: grid; gap: 7px; }
|
||||
.generated-list { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.generated-list code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; white-space: nowrap; }
|
||||
.admin-table tr:last-child td { border-bottom: 0; }
|
||||
.status-已过期, .status-已兑换 { color: var(--color-ink-secondary); }
|
||||
.empty-cell { color: var(--color-ink-secondary); text-align: center !important; }
|
||||
|
||||
@@ -465,12 +613,12 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.sidebar { background: var(--sidebar-solid); backdrop-filter: none; }
|
||||
.chat-header, .composer-wrap, .admin-header { background: var(--color-canvas); backdrop-filter: none; }
|
||||
.chat-header, .composer-wrap { background: var(--color-canvas); backdrop-filter: none; }
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
:root { --color-ink-secondary: var(--color-ink-strong); --color-ink-tertiary: var(--color-ink-secondary); --color-border: var(--color-border-strong); }
|
||||
.chat-header, .composer-wrap, .admin-header { backdrop-filter: none; }
|
||||
.chat-header, .composer-wrap { backdrop-filter: none; }
|
||||
.button-primary, .button-secondary, input, select, .composer { border-width: 2px; }
|
||||
.session-list [data-active="true"]::before { width: var(--space-1); }
|
||||
.sidebar, .account-menu-popup, [role="tooltip"] { border-width: 2px; }
|
||||
@@ -535,12 +683,15 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
[data-sidebar="sidebar"]:focus { outline: none; }
|
||||
.brand-row { display: flex; align-items: center; min-height: 48px; gap: var(--space-3); padding: 0 var(--space-2); font-family: var(--font-display); font-size: var(--type-title-lg); letter-spacing: -.3px; }
|
||||
.brand-row strong { font-weight: 400; }
|
||||
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
|
||||
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
|
||||
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
|
||||
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
|
||||
.report-nav-button:hover,
|
||||
.new-chat:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
/* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the
|
||||
action color scarce, so it stays tinted text on a transparent row rather
|
||||
than a filled block — no page-scale color surface. */
|
||||
.new-chat:hover { background: var(--sidebar-accent); color: var(--color-action-hover); }
|
||||
.new-chat svg,
|
||||
.report-nav-button svg,
|
||||
.sidebar-label > svg,
|
||||
@@ -559,7 +710,9 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-ink-tertiary);
|
||||
/* A section heading has to outrank its own body copy. Both sat on
|
||||
ink-tertiary, so the whole nav read as one flat grey. */
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
@@ -862,7 +1015,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
padding: var(--space-8) var(--space-8) var(--space-16);
|
||||
}
|
||||
.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; }
|
||||
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
|
||||
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.message-content { min-width: 0; max-width: min(80%, 680px); }
|
||||
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
|
||||
@@ -1183,6 +1336,20 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-markdown strong { color: var(--color-ink); font-weight: 500; }
|
||||
.message-markdown a { text-underline-offset: 3px; color: var(--color-action); }
|
||||
.message-markdown code { padding: 2px 5px; font-size: .9em; border-radius: var(--radius-xs); background: var(--color-canvas-muted); font-family: var(--font-mono); }
|
||||
/* Fenced blocks had no rule at all: a bare <pre> keeps `white-space: pre` and no
|
||||
overflow, so one long line spilled out of the message column, while the <code>
|
||||
inside still wore the inline chip background. The block scrolls inside itself
|
||||
and the chip styling is reset. */
|
||||
.markdown-code { margin: 0 0 14px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
|
||||
.markdown-code-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); min-height: 36px; padding: 0 var(--space-2) 0 var(--space-4); border-bottom: 1px solid var(--color-border); }
|
||||
.markdown-code-language { color: var(--color-ink-tertiary); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
|
||||
.markdown-code-copy { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 28px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-caption); transition: background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.markdown-code-copy svg { width: 13px; height: 13px; }
|
||||
.markdown-code pre { margin: 0; padding: 0; overflow: hidden; background: none; }
|
||||
.markdown-code pre > code { display: block; overflow-x: auto; padding: var(--space-3) var(--space-4); border-radius: 0; background: none; font-size: 13px; line-height: 1.6; white-space: pre; -webkit-overflow-scrolling: touch; }
|
||||
@media (hover: hover) {
|
||||
.markdown-code-copy:hover { background: var(--color-canvas-strong); color: var(--color-ink); }
|
||||
}
|
||||
.technique-audit {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
@@ -1403,7 +1570,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.redeem-modal { width: min(100%, 420px); }
|
||||
.paywall-modal { width: min(100%, 480px); }
|
||||
.logout-modal { width: min(100%, 400px); }
|
||||
.account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
|
||||
.account-modal h2, .auth-panel h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
|
||||
.account-modal h2 { margin: 0; font-size: var(--type-display-sm); letter-spacing: -.025em; }
|
||||
.account-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding-bottom: var(--space-5); }
|
||||
.dialog-close { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
|
||||
@@ -1485,25 +1652,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.admin-page { background: var(--color-canvas-soft); }
|
||||
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
|
||||
.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; }
|
||||
.admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
|
||||
.admin-header h1 { font-size: var(--type-display-md); }
|
||||
.admin-scroll { display: grid; width: min(1200px, 100%); gap: var(--space-5); margin: 0 auto; padding: var(--space-8) var(--space-8) var(--space-16); }
|
||||
.admin-section { border: 1px solid var(--color-border); min-width: 0; padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.code-form > * { min-width: 0; }
|
||||
.section-title h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; letter-spacing: -.3px; }
|
||||
.section-title p { margin: 6px 0 0; color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.generated-list > div { min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 0 12px; border: 0; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
|
||||
.generated-list code, .admin-table code { font-family: var(--font-mono); }
|
||||
.generated-list code { font-family: var(--font-mono); }
|
||||
.generated-list button { min-width: 44px; min-height: 44px; border: 0; background: transparent; cursor: pointer; color: var(--color-action); }
|
||||
.admin-table-wrap { margin-top: 16px; overflow: auto; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.admin-table th, .admin-table td { border-bottom: 1px solid var(--color-border); text-align: left; padding: var(--space-4); color: var(--color-ink); font-size: 13px; }
|
||||
.admin-table th { background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-weight: 500; }
|
||||
.code-status { display: inline-flex; min-height: 28px; align-items: center; padding: 0 9px; border-radius: var(--radius-md); background: var(--color-canvas-muted); color: var(--color-ink-secondary); }
|
||||
.status-可用 { background: var(--color-success-muted); color: var(--color-success); }
|
||||
.status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); }
|
||||
|
||||
@media (hover: hover) {
|
||||
.new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
.new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); }
|
||||
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
|
||||
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
@@ -1525,7 +1685,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.chat-app { grid-template-columns: 1fr; }
|
||||
[data-sidebar="sidebar"] { position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100dvh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
|
||||
/* The glass surface assumes a light backdrop. On mobile the drawer floats
|
||||
above the scrim instead, which darkens and flattens it, so the drawer
|
||||
takes the solid token — same treatment as prefers-reduced-transparency. */
|
||||
[data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100dvh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
|
||||
[data-sidebar="sidebar"][data-mobile-open="false"] { visibility: hidden; transform: translateX(-100%); }
|
||||
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
|
||||
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
|
||||
@@ -1563,8 +1726,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.auth-story > div > p, .auth-footnote { display: none; }
|
||||
.auth-panel { justify-content: flex-start; padding: var(--space-8) var(--space-6) max(var(--space-8), env(safe-area-inset-bottom)); }
|
||||
.auth-panel h1 { font-size: var(--type-display-md); }
|
||||
.admin-header { padding: 0 var(--space-4); }
|
||||
.admin-scroll { padding: var(--space-6) var(--space-4) var(--space-12); }
|
||||
.birth-time-event-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -1589,7 +1750,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.auth-story h2 { font-size: var(--type-title-lg); }
|
||||
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
|
||||
.auth-panel h1 { font-size: var(--type-display-sm); }
|
||||
.admin-section { padding: var(--space-5); }
|
||||
}
|
||||
.session-delete-overlay { z-index: 100; }
|
||||
.session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); }
|
||||
@@ -1702,7 +1862,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(112deg, transparent 22%, rgba(255, 255, 255, .54) 46%, transparent 68%);
|
||||
background: linear-gradient(112deg, transparent 22%, var(--sheen) 46%, transparent 68%);
|
||||
content: "";
|
||||
transform: translateX(-48%);
|
||||
transition: opacity 220ms ease-out, transform 520ms var(--ease-out);
|
||||
@@ -2170,7 +2330,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer {
|
||||
min-height: 64px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 1px 2px rgba(29, 29, 31, .05), 0 12px 32px -24px rgba(29, 29, 31, .34);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -2863,6 +3023,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: start; }
|
||||
}
|
||||
|
||||
/* The QR stays on literal white in both themes: scanners need the light modules
|
||||
to actually be light, so this surface is not themed. */
|
||||
.payment-qr-wrap { position: relative; width: min(220px, 72vw); aspect-ratio: 1; margin: 14px auto; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow-elevated); }
|
||||
.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
|
||||
@@ -2988,7 +3150,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-chart-card h3 { margin: 0; font-size: 18px; }
|
||||
.personal-report-claim-status { flex: 0 0 auto; color: var(--color-ink-secondary); font-size: 11px; font-weight: 650; line-height: 1.5; }
|
||||
.personal-report-claim-status.is-success { color: var(--color-success); }
|
||||
.personal-report-claim-status.is-warning { color: #866018; }
|
||||
.personal-report-claim-status.is-warning { color: var(--color-warning); }
|
||||
.personal-report-claim-status.is-danger { color: var(--color-danger); }
|
||||
.personal-report-table-wrap { max-width: 100%; overflow-x: auto; }
|
||||
.personal-report-planet-table { margin-top: 18px; font-size: 12px; }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// 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 { cloneElement, type CSSProperties, type ReactElement } from "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 border = "var(--color-border, #d8d6cf)";
|
||||
const focus = "var(--color-focus, #85432f)";
|
||||
const canvas = "var(--bp-canvas)";
|
||||
const ink = "var(--bp-ink)";
|
||||
const inkSecondary = "var(--bp-ink-soft)";
|
||||
const border = "var(--bp-border)";
|
||||
const focus = "var(--bp-action)";
|
||||
|
||||
const actionStyle = {
|
||||
alignItems: "center",
|
||||
@@ -56,6 +60,35 @@ export default function RootNotFound() {
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bp-canvas: #fbfaf7;
|
||||
--bp-ink: #1d1d1f;
|
||||
--bp-ink-soft: #5f5f59;
|
||||
--bp-border: #d8d6cf;
|
||||
--bp-action: #85432f;
|
||||
--bp-danger: #9a2f2f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bp-canvas: #262624;
|
||||
--bp-ink: #f2f0ea;
|
||||
--bp-ink-soft: #b3afa4;
|
||||
--bp-border: #3c3b37;
|
||||
--bp-action: #d4785a;
|
||||
--bp-danger: #e08573;
|
||||
}
|
||||
.root-not-found-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
|
||||
`}</style>
|
||||
<p style={{ color: inkSecondary, fontSize: "14px", fontWeight: 500, letterSpacing: "0.12em", margin: 0 }}>
|
||||
|
||||
@@ -230,7 +230,7 @@ export function AppSidebar({
|
||||
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
</summary>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{favoriteSessions.length === 0 ? <p className="sidebar-empty">还没有收藏</p> : (
|
||||
{favoriteSessions.length === 0 ? <p className="sidebar-empty">还没有收藏,可在对话的「更多操作」里收藏</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
||||
</SidebarMenu>
|
||||
@@ -247,7 +247,7 @@ export function AppSidebar({
|
||||
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
</summary>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
|
||||
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话,点上方「新建对话」开始</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { MarkdownCodeBlock } from "@/components/markdown-code-block";
|
||||
import { promoteDefinitionLists } from "@/lib/chat-definition-lists";
|
||||
|
||||
const markdownComponents: Components = {
|
||||
@@ -16,6 +17,7 @@ const markdownComponents: Components = {
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
|
||||
ul: ({ children }) => <ul className="markdown-list">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="markdown-list">{children}</ol>,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
|
||||
function fencedLanguage(node: ReactNode): string {
|
||||
if (!isValidElement<{ className?: string }>(node)) return "";
|
||||
return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? "";
|
||||
}
|
||||
|
||||
/** The raw source inside the fence, for the copy button. */
|
||||
function fencedSource(node: ReactNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(fencedSource).join("");
|
||||
if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children);
|
||||
return "";
|
||||
}
|
||||
|
||||
export function MarkdownCodeBlock({ children }: { readonly children?: ReactNode }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const language = fencedLanguage(children);
|
||||
const source = fencedSource(children);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
}, []);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(source);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setCopied(false), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="markdown-code">
|
||||
<div className="markdown-code-bar">
|
||||
<span className="markdown-code-language">{language || "代码"}</span>
|
||||
<button
|
||||
aria-label={copied ? "已复制代码" : "复制代码"}
|
||||
className="markdown-code-copy"
|
||||
onClick={() => void copy()}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
<span>{copied ? "已复制" : "复制"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
/** Declarations of the block that starts at `opener`, up to its closing brace. */
|
||||
function blockTokens(opener: string): Map<string, string> {
|
||||
const start = globalStyles.indexOf(opener);
|
||||
assert.notEqual(start, -1, `missing block: ${opener}`);
|
||||
const end = globalStyles.indexOf("\n}", start);
|
||||
assert.notEqual(end, -1, `unterminated block: ${opener}`);
|
||||
const body = globalStyles.slice(start, end);
|
||||
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)]
|
||||
.map((hit) => [hit[1], hit[2].trim().replace(/\s+/g, " ")]));
|
||||
}
|
||||
|
||||
const light = blockTokens(":root {\n color-scheme: light;");
|
||||
const preferred = blockTokens(':root:not([data-theme="light"]) {');
|
||||
const pinned = blockTokens(':root[data-theme="dark"] {');
|
||||
|
||||
// A token whose value is the same in both themes on purpose. Everything else
|
||||
// must be redefined, or dark mode ships a light-theme value on a dark ground.
|
||||
const themeNeutral = new Set([
|
||||
"font-display", "font-body", "font-mono", "ease-out", "composer-reserve",
|
||||
]);
|
||||
|
||||
test("the OS-preference and pinned dark blocks never drift apart", () => {
|
||||
// Plain CSS cannot share a declaration list, so the dark palette is written
|
||||
// twice. This is the guard that keeps the copies honest.
|
||||
assert.deepEqual([...pinned.keys()].sort(), [...preferred.keys()].sort());
|
||||
for (const [token, value] of pinned) {
|
||||
assert.equal(preferred.get(token), value, `--${token} differs between the two dark blocks`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every themeable token has a dark value", () => {
|
||||
const themeable = [...light.keys()].filter((token) => (
|
||||
!themeNeutral.has(token)
|
||||
&& (token.startsWith("color-") || token.startsWith("shadow-")
|
||||
|| token.startsWith("report-") || token === "ring-hairline" || token === "sheen")
|
||||
));
|
||||
const missing = themeable.filter((token) => !pinned.has(token)).sort();
|
||||
assert.deepEqual(missing, [], `no dark value for: ${missing.join(", ")}`);
|
||||
assert.ok(themeable.length >= 30, `only ${themeable.length} themeable tokens found`);
|
||||
});
|
||||
|
||||
test("both themes declare color-scheme so form controls and scrollbars follow", () => {
|
||||
assert.equal(light.get("color-scheme") ?? "light", "light");
|
||||
assert.match(globalStyles, /:root:not\(\[data-theme="light"\]\) \{\n\s*color-scheme: dark;/);
|
||||
assert.match(globalStyles, /:root\[data-theme="dark"\] \{\n\s*color-scheme: dark;/);
|
||||
});
|
||||
|
||||
test("dark ink reads lighter than dark canvas, and the action stays legible", () => {
|
||||
const luminance = (hex: string) => {
|
||||
const channel = (value: number) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
};
|
||||
const contrast = (a: string, b: string) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const canvas = pinned.get("color-canvas")!;
|
||||
for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) {
|
||||
const value = pinned.get(token)!;
|
||||
assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`);
|
||||
assert.ok(
|
||||
contrast(value, canvas) >= 4.5,
|
||||
`--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`,
|
||||
);
|
||||
}
|
||||
// Elevation reads through lightness on dark: floor is darkest, raised steps up.
|
||||
const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"]
|
||||
.map((token) => luminance(pinned.get(token)!));
|
||||
for (let i = 1; i < steps.length; i += 1) {
|
||||
assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const markdownView = readFileSync(new URL("../src/components/chat-markdown-view.tsx", import.meta.url), "utf8");
|
||||
const codeBlock = readFileSync(new URL("../src/components/markdown-code-block.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("a fenced code block scrolls inside itself instead of spilling out of the message", () => {
|
||||
// There was no rule for fenced blocks at all. A bare <pre> keeps
|
||||
// `white-space: pre` with no overflow, so one long line pushed past the
|
||||
// message column; the fix has to keep both properties together.
|
||||
const body = cssDeclarations(".markdown-code pre > code", globalStyles);
|
||||
assert.match(body, /overflow-x:\s*auto/);
|
||||
assert.match(body, /white-space:\s*pre\b/);
|
||||
assert.match(cssDeclarations(".markdown-code", globalStyles), /overflow:\s*hidden/);
|
||||
});
|
||||
|
||||
test("the block resets the inline-code chip styling", () => {
|
||||
// `.message-markdown code` paints a small chip for inline code. Inside a
|
||||
// fence that chip has to go, or every block gets a nested background.
|
||||
assert.match(cssDeclarations(".markdown-code pre > code", globalStyles), /background:\s*none/);
|
||||
assert.match(cssDeclarations(".markdown-code pre", globalStyles), /background:\s*none/);
|
||||
});
|
||||
|
||||
test("react-markdown routes fences through the block, not a bare pre", () => {
|
||||
assert.match(markdownView, /pre: \(\{ children \}\) => <MarkdownCodeBlock>/);
|
||||
});
|
||||
|
||||
test("the copy control is labelled and the language is announced as text", () => {
|
||||
assert.match(codeBlock, /aria-label=\{copied \? "已复制代码" : "复制代码"\}/);
|
||||
assert.match(codeBlock, /language \|\| "代码"/);
|
||||
// The copy target is the raw fence source, not rendered markup.
|
||||
assert.match(codeBlock, /navigator\.clipboard\.writeText\(source\)/);
|
||||
// A rejected clipboard permission must not flip the button to "copied".
|
||||
assert.match(codeBlock, /catch \{\s*return;\s*\}/);
|
||||
});
|
||||
|
||||
test("the copy timer is cleared when the block unmounts", () => {
|
||||
// Streaming replaces these blocks constantly; a dangling timer would set
|
||||
// state on an unmounted node.
|
||||
assert.match(codeBlock, /useEffect\(\(\) => \(\) => \{[\s\S]*clearTimeout\(resetTimer\.current\)/);
|
||||
});
|
||||
@@ -36,6 +36,52 @@ test("sidebar nav items read left-aligned everywhere; centering is the desktop r
|
||||
);
|
||||
});
|
||||
|
||||
test("新建对话 carries the sidebar's only accent; 我的报告 stays neutral", () => {
|
||||
// The sidebar had no accent pixel at all in the empty state: its only two
|
||||
// action-colored surfaces are the 2px active-session bar and the terracotta
|
||||
// profile initial, and neither renders with no sessions and an uploaded
|
||||
// avatar. DESIGN.md keeps the action color scarce, so exactly one row gets
|
||||
// it — as tinted text, not a filled block, so the surface stays light.
|
||||
assert.match(topLevelRule(".new-chat"), /color:\s*var\(--color-action\)/);
|
||||
assert.doesNotMatch(topLevelRule(".new-chat"), /background:\s*var\(--color-action\)/);
|
||||
assert.match(topLevelRule(".report-nav-button"), /color:\s*var\(--sidebar-foreground\)/);
|
||||
assert.doesNotMatch(topLevelRule(".report-nav-button"), /--color-action/);
|
||||
// Hover deepens the same hue rather than reverting to ink.
|
||||
for (const body of [...globalStyles.matchAll(/\.new-chat(?::not\(:disabled\))?:hover\s*\{([^}]*)\}/g)]) {
|
||||
assert.match(body[1], /color:\s*var\(--color-action-hover\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the mobile drawer is opaque; the glass surface is a desktop treatment", () => {
|
||||
// rgba(235,233,227,.86) assumes a light backdrop. On mobile the drawer sits
|
||||
// above the scrim, which showed through and pulled #EBE9E3 down to #E3E1DC.
|
||||
assert.match(
|
||||
globalStyles,
|
||||
/@media\s*\(max-width:\s*767px\)[\s\S]*\[data-sidebar="sidebar"\]\s*\{[^}]*background:\s*var\(--sidebar-solid\)[^}]*backdrop-filter:\s*none/,
|
||||
);
|
||||
// Desktop keeps the glass.
|
||||
assert.match(topLevelRule(".sidebar"), /background:\s*var\(--sidebar-background\)/);
|
||||
assert.match(topLevelRule(".sidebar"), /backdrop-filter:\s*saturate/);
|
||||
});
|
||||
|
||||
test("a section heading outranks its own body copy", () => {
|
||||
// Headings and their empty-state copy both sat on ink-tertiary, so the nav
|
||||
// read as one flat grey with no hierarchy to scan.
|
||||
const heading = cssDeclarations(".sidebar-label, .sidebar-section-summary", globalStyles);
|
||||
assert.match(heading, /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-ink-secondary/);
|
||||
assert.match(topLevelRule(".sidebar-empty"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
});
|
||||
|
||||
test("empty sidebar sections name the next step instead of dead-ending", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
// Neutral on purpose: 新建对话 already carries the accent a few rows above,
|
||||
// and a second tinted call to action would compete with it.
|
||||
assert.match(appSidebar, /还没有收藏,可在对话的「更多操作」里收藏/);
|
||||
assert.match(appSidebar, /暂无对话,点上方「新建对话」开始/);
|
||||
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-action/);
|
||||
});
|
||||
|
||||
test("provides the generic composable sidebar primitive", () => {
|
||||
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);
|
||||
});
|
||||
@@ -340,8 +386,12 @@ test("nests sidebar lists under one heading scale without an archive toggle", ()
|
||||
assert.match(cssBlock(".session-title"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".session-title"), /font-weight:\s*500/);
|
||||
assert.match(cssBlock(".session-row"), /color:\s*var\(--sidebar-foreground\)/);
|
||||
assert.match(cssBlock(".sidebar-label"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
// Was ink-tertiary, identical to .sidebar-empty below it, which left the
|
||||
// headings and their own body copy indistinguishable. The shared scale is
|
||||
// what this test guards; the rank between the two levels is asserted in
|
||||
// "a section heading outranks its own body copy".
|
||||
assert.match(cssBlock(".sidebar-label"), /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.match(cssBlock(".sidebar-label"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".new-chat"), /font-size:\s*var\(--type-body-sm\)/);
|
||||
|
||||
Reference in New Issue
Block a user