From 66d9f59d848ed5487f956be0a3f12955f3a69aa0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 04:53:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=A0=87=E9=A2=98=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=90=BD=E5=AE=8B=E4=BD=93=EF=BC=8C=E5=BC=BA=E8=B0=83?= =?UTF-8?q?=E8=89=B2=E5=88=86=E9=98=B6=E6=8D=A2=20coral=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E5=B8=B8=E9=A9=BB=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=EF=BC=88BUG-737/738=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- CHANGELOG.md | 14 ++ docs/BUG_HISTORY.md | 37 ++++ docs/tasks/PROGRESS-cend-ui-r1-20260916.md | 180 ++++++++++++++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 66 +++++-- frontend/src/app/admin/admin.css | 2 +- frontend/src/app/globals.css | 166 +++++++++------- frontend/src/app/page.tsx | 22 +-- frontend/src/components/admin/admin-app.tsx | 2 +- frontend/src/components/chat-composer.tsx | 50 +++-- .../composer-character-remaining.tsx | 9 - .../components/rectification-agentic-chat.tsx | 23 +-- .../character-remaining-contract.test.ts | 11 +- frontend/tests/chat-session-write.test.ts | 6 +- frontend/tests/chat-stream-layout.test.ts | 2 +- .../tests/consultation-entrypoint.test.ts | 6 +- frontend/tests/design-token-contract.test.ts | 7 +- .../font-stack-loadable-contract.test.ts | 108 +++++++++++ .../personal-report-theme-contract.test.ts | 8 +- .../tests/rectification-agentic-entry.test.ts | 5 +- .../tests/session-conversation-layout.test.ts | 8 +- .../site-style-isolation-contract.test.ts | 5 +- 22 files changed, 580 insertions(+), 159 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-ui-r1-20260916.md delete mode 100644 frontend/src/components/composer-character-remaining.tsx create mode 100644 frontend/tests/font-stack-loadable-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4263b98..9adafbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 界面标题不再是宋体,强调色换成暖珊瑚,输入框下方那条空栏删掉 + +**标题字体。** 全站的标题——品牌字、对话标题、首页大标题、**回答正文里的小标题**、各种弹窗标题——此前在真实设备上都是以宋体渲染的:苹果机型落到「Songti SC」,Windows 落到「SimSun」。原因是字体栈里排在最前面的两个字体本仓从来没有加载过,浏览器一路往后掉,掉到了中文衬线上。宋体是很好的印刷体,但在屏幕上做小字号界面标题时笔画偏细、发虚,和正文字重也对不上。现在标题和正文用同一套无衬线字体,标题靠**字重**区分层级。 + +一并评估过「拉丁字母保留衬线、汉字用无衬线」的方案,实测后放弃:为一个品牌字引入的衬线字体要多下载 132 KB(是现有正文字体的 2.7 倍),而且会把「D10 事业盘怎么读」这种中英混排的标题劈成两种字形,看起来像字体没加载出来。 + +**强调色。** 亮色主题的强调色从深棕换成更暖更亮的珊瑚色。此前暗色主题早就用的是珊瑚色,亮色还停在深棕,同一个产品两套观感。需要说明的是:珊瑚色直接当文字色时和背景的对比度不够(正文和链接会变得不好读),所以按用途分成两阶——**按钮、头像、当前会话的色条、焦点框**用亮珊瑚,**文字、链接、图标**用它的深一阶。你在界面上看到的"变珊瑚了"主要来自前者。 + +**输入框。** 模型选择器从输入框下方挪进了输入框内部的底排,输入框下面那条一直占着位置的横栏删掉了——它此前不论有没有内容都固定占 44 像素高。字数提示也改成只在接近上限时才出现,不再有一个常驻的空容器。 + +**顶栏。** 从 68 像素收到 46 像素,「分析对象:」这个说法去掉,只留星盘资料的名字,显示为标题旁一枚浅色小标签;手机上不显示。加上输入框那条,每屏固定占用的界面框架从 216 像素降到 170 像素。 + +Skill 版本不变(纯界面改动)。 + ## 2026-09-16 — 设置弹窗不再随分区改变大小,分区菜单去掉左侧色条 设置弹窗的四个分区(个人资料 / 星盘资料 / 账户与点数 / 通用设置)现在在任何浏览器上都保持同一个外框大小,切换分区时只有右侧内容在换。此前固定高度只用了较新的 `dvh` 单位写,不认识这个单位的浏览器会把整条高度丢掉,弹窗就退回按内容撑开——内容多的分区高、内容少的分区矮,看起来就是「选别的就立马缩小了」。同一处写法在登录页、后台页面和手机侧栏也一并修好。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 33edf60a..96f4228c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11478,6 +11478,43 @@ - 复发自:无 - 修复版本:待发布 +## BUG-737 | `--font-display` 里没有一个字体能加载,中文标题全站落到宋体 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`frontend/src/app/globals.css`(`--font-display` / `--font-body`)、`frontend/src/app/admin/admin.css`、`frontend/src/components/admin/admin-app.tsx`;波及 20 条使用 `var(--font-display)` 的规则 +- 用户现象:品牌字、顶栏会话标题、首页大标题、入口卡标题、**助手回答正文里的 `h2`/`h3`**、所有弹窗标题、模型选择器标题、登录页大标题,在真实设备上以宋体渲染——笔画细、小字号发虚,与 Inter 正文的字重对不上。 +- 触发条件:任何用户、任何页面,一直如此。macOS / iOS 命中 `Songti SC`;Windows 拉丁走 `Georgia`、中文掉到浏览器默认 serif(SimSun);多数 Android 无 `Noto Serif CJK SC`,同样落到系统默认 serif。 +- 根因:`--font-display` 的值是 `"Tiempos Headline", "Songti SC", STSong, "Noto Serif CJK SC", Georgia, serif`,`--font-body` 以 `StyreneB` 开头。**这两个是 Anthropic 授权字体,本仓从未加载过它们**:`git grep "@font-face" frontend/src` 无命中,`frontend/public/` 下没有字体文件,`layout.tsx` 只用 `next/font/local` vendor 了 `InterVariable-latin.woff2`。栈首两个名字永远不命中,实际生效的是它们后面的中文衬线回退。 + 更上一层的原因:`frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,该文件自己在 `## Known Gaps` 写明 claude.ai 产品界面不在范围内。它的「display 用 Copernicus 衬线 400」是**拉丁 display face** 的规则,被 `DESIGN.md` 当成实现契约照抄到中文界面,就成了宋体。 +- 为什么没被拦住:没有任何测试或构建检查会验证「声明的 family 是否真的能加载」。相反,`DESIGN.md` §3 把这个栈记录成既定事实,`personal-report-theme-contract` 与 `consultation-entrypoint` 两个合同测试还把「`--font-display` + `font-weight: 400`」锁成了设计契约——等于把 bug 写进了断言。 +- 修复:display 与 body 合并为同一条无衬线栈(`var(--font-inter, Inter)` → 系统 → `PingFang SC` / `Microsoft YaHei`);display 层级改由**字重**承担,33 条 display 规则的 `font-weight: 400` 抬到 `500`。`StyreneB` 从 `--font-body`、`admin.css`、`admin-app.tsx` 一并删除。 + **拉丁衬线方案被实测否决**:Newsreader 拉丁可变子集 132 KB(是整个 Inter 文件的 2.7 倍),只为给一个品牌字上衬线;且会把「D10 事业盘怎么读」这类混排标题劈成两种字形,读起来像字体加载失败。**CJK 不用衬线**是本条的结论。 +- 验证:`git grep -nE "Tiempos|StyreneB|Songti|STSong" frontend/src` 除解释性注释外无命中;`tsc --noEmit` 0 错;`npm run lint` 0 error / 118 warning(与基线同);`npm test` 3346 条、pass 3300 / fail 31,失败清单与基线**逐条 diff 一致**(均为无 Docker);`next build` 后 `/` 仍 `○ Static`,产物 CSS gzip 41,095 → 41,096 字节(+0.002%)。 +- 防复发:新增 `frontend/tests/font-stack-loadable-contract.test.ts`——断言 `--font-display` / `--font-body` 里每一个**带引号的 family** 都必须在 `layout.tsx` 里有对应的 `next/font/local` 声明,否则必须属于系统字体白名单。声明一个加载不到的 family 会直接打红。 + 另:`frontend/CLAUDE_DESIGN.md` 是**营销官网**的设计系统,不是产品界面的实现契约;引用它之前先读它自己的 `## Known Gaps`。 +- 相关记录:BUG-738(同轮的强调色分阶) +- 复发自:无 +- 修复版本:待发布 + +## BUG-738 | 亮暗强调色不同源,且 `--color-ring` 在 `@theme inline` 里硬编码不跟随 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`frontend/src/app/globals.css` 的四处亮色声明(`@theme inline` 的 `--color-ring`、`:root`、`@media print` 里的 `:root`)、`frontend/DESIGN.md` +- 用户现象:亮色下强调色是深棕 `#85432f`,暗色下已经是 coral `#d78064`,同一个产品两套观感。 +- 触发条件:切换明暗主题。 +- 根因:暗色块早就带着注释「`#85432f` is too dark to read as an action on」自行改用了 coral,亮色没跟。另有一处更隐蔽的:`--color-ring` 写在 `@theme inline` 块里,是**硬编码十六进制**而不是 `var(--color-focus)`,所以它不跟随 `:root`——任何改强调色的轮次只要没单独改它,就会出现「按钮变了、Tailwind `ring-*` 的焦点环没变」。 +- 修复:产品拍板亮色换 Claude coral `#cc785c`。实测发现它**不能直接当文字色**:对 `#fbfaf7` 画布只有 **3.14:1**,而 62 个调用点里 **53 个是 `color:`**(含回答里的 markdown 链接)。因此按角色拆成两阶——`--color-action: #a9583e`(文字与图标,4.85:1)与 `--color-action-strong: #cc785c`(填充、导轨、焦点环、描边,适用 3:1 非文字阈值)。`--color-ring` 改为 `var(--color-focus)`;`@media print` 里的第四处 `:root` 同步;暗色两块补上 `--color-action-strong`(与 `--color-action` 同值 `#d78064`,暗底上一阶就够)。 +- 已知缺口(产品已知悉并接受):白字落在 `#cc785c` 填充上是 **3.28:1**,低于正文 AA。涉及 `.button-primary` 与两个头像圆。claude.ai 本身就是这个值,而本轮的目标正是「像 Claude」。正文不受影响——那正是 `--color-action` 这一阶要保护的。 +- 验证:`git grep -n "#85432f" frontend/src/app/globals.css` 只剩 `--report-accent` 两处(刻意保留,见下);对比度按 WCAG 相对亮度公式手算并记入进度记录;套件与基线逐条一致。 +- 防复发:`design-token-contract.test.ts` 新增断言 `--color-ring: var(--color-focus)`,并把两阶 token 的顺序锁进 `:root` 正则。改强调色时漏掉 `@theme inline` 那处会直接打红。 +- 相关记录:BUG-737(同轮);`--report-accent` 保持 `#85432f` 是**刻意的**——报告是打印纸面,不跟应用强调色走,见 `DESIGN.md` §2 末尾的说明,不要「修」这个不一致。 +- 复发自:无 +- 修复版本:待发布 + ## BUG-698 | 设置弹窗尺寸随分区变化:dvh 无回退,且仓库唯一那处回退被压缩器吃掉 - 状态:investigating diff --git a/docs/tasks/PROGRESS-cend-ui-r1-20260916.md b/docs/tasks/PROGRESS-cend-ui-r1-20260916.md new file mode 100644 index 00000000..bb73a8fd --- /dev/null +++ b/docs/tasks/PROGRESS-cend-ui-r1-20260916.md @@ -0,0 +1,180 @@ +# PROGRESS · C 端界面对齐 R1(字体、强调色、chrome 瘦身) + +任务书:`docs/tasks/TASK-cend-ui-claude-alignment-20260916.md` 的 R1 +执行方式:**直接执行模式**(产品 2026-09-16 授权「你直接执行任务书就可以」) +分支:`codex/cend-ui-r1-20260916` +基线:`origin/staging` = `3fd74daf` +BUG 段:BUG-737、BUG-738 + +--- + +## 开工基线实测 + +| 项 | 基线值 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error / 118 warning | +| `npm test` | tests 3346 · pass 3300 · **fail 31** · skipped 15 | +| 路由渲染标记 | `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ` · `/reports/[reportId]` `ƒ` | +| 产物 CSS gzip | 41,095 字节(四个 css chunk 合计) | + +31 条失败全部是无 Docker 的既有环境缺口,清单存档比对用。 + +**首屏 gzip 口径说明**:本仓没有 gzip 测量脚本,当前 Turbopack 的路由表也不再输出 Size 列。R1 的改动几乎全在 CSS,因此口径取 **`.next/static` 下全部 css chunk 的 gzip 字节合计**,改前改后同机同命令测量。 + +--- + +## T1.1 · 字体栈(BUG-737) + +### 做了什么 + +`--font-display` 与 `--font-body` 合并为同一条无衬线栈,display 层级改由**字重**承担(33 条 display 规则 `font-weight: 400` → `500`)。`StyreneB` 从 `--font-body`、`admin.css`、`admin-app.tsx` 一并删除。 + +### 让步顺序 1 被触发:拉丁衬线方案实测后否决 + +任务书首选「vendor 一个 OFL 拉丁衬线」。实测后退到让步项「拉丁也走 Inter」,依据两条: + +| 证据 | 数字 | +|---|---| +| Newsreader 拉丁可变子集(OFL,取自 Google Fonts v26) | **132,000 字节** | +| 对比:本仓已 vendor 的 `InterVariable-latin.woff2` | 48,256 字节 | +| 倍数 | **2.7×** | + +第二条理由比体积更重要:`--font-display` 的使用面(品牌字、会话标题、首页大标题、入口卡标题、**回答正文的 h2/h3**、弹窗标题、登录页)**压倒性是中文**。拉丁衬线在里面只会命中品牌字和零星拉丁标签,却会把「D10 事业盘怎么读」这类混排标题劈成两种字形——读起来像字体加载失败,比统一无衬线更糟。 + +任务书 D4「中文永远不用衬线」不可退让的部分完整保留。 + +### 验收对照 + +| 任务书验收标准 | 结果 | +|---|---| +| `git grep -nE "Tiempos\|StyreneB\|Songti\|STSong" frontend/src` 无命中 | ✅ 除解释性注释外无命中 | +| 新增字体文件带 OFL 许可证、体积入记录 | ➖ 不适用(未 vendor 新字体,见上) | +| 新增 token 合同测试 | ✅ `frontend/tests/font-stack-loadable-contract.test.ts`,4 条全绿 | +| `docs/BUG_HISTORY.md` 新增 BUG-737 `resolved` | ✅ | +| `/` 仍 `○ Static`;gzip 入记录 | ✅ 见下 | + +新测试断言四件事:两条栈里每个带引号 family 必须是 vendored 或系统字体;两条栈必须经 `var(--font-inter, Inter)`;`--font-display` 不得触达任何 CJK 衬线或泛型 `serif`;不得有 display 规则停在字重 400。 + +--- + +## T1.2 · 强调色换 coral(BUG-738) + +### 与任务书 D2 的偏离,及依据 + +产品拍板亮色换 Claude coral `#cc785c`。**实测发现它不能直接当文字色**——按 WCAG 相对亮度公式手算: + +| 前景 / 背景 | 对比度 | 正文 AA(4.5:1) | +|---|---:|---| +| `#cc785c` 文字 on `#fbfaf7` 画布 | **3.14** | ✗ | +| `#85432f`(原值)on 同背景 | 7.08 | ✓ AAA | +| `#a9583e` on 同背景 | **4.85** | ✓ | +| 白字 on `#cc785c` 填充 | **3.28** | ✗(≥3:1,满足非文字 UI 阈值) | + +而 `--color-action` 的 62 个调用点里 **53 个是 `color:`**(`background` 9 个、`border` 37 个),包括**助手回答正文里的 markdown 链接**。直接把它设成 `#cc785c` 会让 53 处读起来的文字全部掉到 3.14:1。 + +因此按**角色分两阶**: + +| token | 值 | 承担 | +|---|---|---| +| `--color-action` | `#a9583e` | 文字、图标、链接(4.85:1) | +| `--color-action-strong` | `#cc785c` | 填充、导轨、焦点环(3:1 非文字阈值) | +| `--color-action-hover` | `#8f4630` | 按下态 | +| `--color-action-soft` | `#f7ece5` | 淡底 | +| `--color-focus` / `--color-ring` | `#cc785c` | 焦点环 | + +9 处 `background: var(--color-action)` 与 `.button-primary` 的 border 全部改指 `-strong`;37 处 border 保持 `--color-action`(多为低透明度 `color-mix`,肉眼无差)。 + +**需要产品确认的一条**:白字落在 `#cc785c` 填充上是 3.28:1,低于正文 AA,涉及 `.button-primary` 与两个头像圆。claude.ai 本身就是这个值,而本轮目标正是「像 Claude」,故保留并在 `DESIGN.md` 与 BUG-738 里写明为**已知缺口**。若产品要求达 AA,把这几处填充改回 `--color-action`(白字 5.06:1)即可,视觉上仍是 coral。 + +### 四处亮色声明同步情况 + +| 位置 | 处理 | +|---|---| +| `@theme inline` 的 `--color-ring`(原硬编码 `#85432f`,**任务书点名的易漏点**) | ✅ 改为 `var(--color-focus)`,并新增断言锁住 | +| `:root` 主声明 | ✅ | +| `@media print` 里的第四个 `:root` | ✅ | +| `--report-accent`(`:root` 与 print 两处) | ✅ **刻意保持 `#85432f`**(D3) | +| 暗色两块 | ✅ 补 `--color-action-strong: #d78064`,两块 token 集合仍完全一致 | + +--- + +## T1.3 · 模型选择器进输入框,删常驻底栏 + +`ChatComposer` 从横向 flex 改为纵向:文本域在上,自己的底排(`.composer-row`)在下,左侧 `toolbar` 插槽放模型选择器,右侧是字数与发送/停止键。`.composer-footer` 的三条 CSS 规则与两个调用方(`page.tsx`、`rectification-agentic-chat.tsx`)的 footer div 全部删除;`composer-character-remaining.tsx` 整个文件删除(计数改由 composer 用自己已有的 `draft` 内联渲染,对自带 `value` 的校正面反而更准)。 + +`--composer-reserve` 148 → 124px(窄屏 116 → 100px)。 + +| 验收标准 | 结果 | +|---|---| +| 输入框为空且未近上限时下方无常驻元素 | ✅ `.composer-row` 只在有 toolbar 或计数时有内容,`.composer-footer` 已不存在 | +| 键盘可达性与 `aria-label` 不弱于改前 | ✅ `aria-describedby` 链路未变(`remainingId` 仍由调用方可覆盖,校正面仍传自己的 `useId()`) | +| composer / model-selector 既有测试全绿 | ✅ | +| `ChatComposer` 仍是唯一输入框实现 | ✅ 校正面走同一组件的 `toolbar` 插槽,未新开第二个 | + +## T1.4 · 顶栏瘦身 + +`.chat-panel` 首行 68 → 46px(`is-rectification` 变体同步)。「分析对象:{盘名}」副标题删除。 + +**触发让步顺序 3**:任务书原方案是把分析对象移进输入框的对象选择器,但那需要 chart-library 状态与会话写接口,会撑大 `page.tsx` 并撞增长门禁(红线 5)。按让步项退到「标题旁一枚静默 chip」(`.chat-header-chart`),768px 以下隐藏,顶栏保持单行。会话与星盘资料的绑定关系本身没动。 + +常驻 chrome 合计:68 + 148 → **46 + 124 px**。 + +--- + +## 收尾验收 + +| 红线 | 结果 | +|---|---| +| 1 · `tsc --noEmit` 0 错 | ✅ | +| 1 · `npm run lint` 0 error | ✅ 0 error / 118 warning(与基线同) | +| 2 · 测试总数不低于基线 | ✅ tests **3346 → 3350**(+4 为新增的字体栈合同),pass 3300 → 3304,fail 仍 31,失败清单与基线 `diff` **逐条一致** | +| 3 · `/` 保持 `○ Static` | ✅ `/chart` `○`、`/ephemeris` `○`、`/reports` `ƒ` 也全部不变 | +| 4 · 首屏 gzip ±2% | ✅ **41,095 → 41,096 字节(+0.002%)** | +| 5 · `page.tsx` 增长门禁 | ✅ 本轮净删(删了 footer div 与一个 import),`home-shell-growth-contract` 绿 | +| 6 · 一律 `ChatComposer` | ✅ | +| 7 · 无 spinner / 骨架 | ✅ 未新增等待态 | +| 8 · 不回退 BUG-698 的 `@supports (height: 1dvh)` | ✅ 未触碰该块 | +| 10 · 同提交更新 `DESIGN.md` | ✅ §2 调色板与「Where the action color appears」重写、§3 字体栈重写、新增「Chat header」段、composer 段更新 | +| 11 · 不升依赖、不顺手修 warning | ✅ warning 数与基线同为 118 | + +### Python 快速门(BUG-736 防复发要求) + +BUG-736 的防复发写明:**改动 `page.tsx` 的轮次,验收必须另跑 `run_quality_gate.py --profile quick`**,因为有 Python 测试拿前端源码当断言对象。本轮改了 `page.tsx`,已跑: + +- `pytest tests/test_supabase_user_data_contract.py` → 8 条全绿 +- `run_quality_gate.py --profile quick` → pytest 段 **798 passed / 1 skipped / 0 failed**;整体退出 1,唯一原因是 `npm test` 段的 31 条失败与无 `rsync`——把门禁的 31 条失败清单与本轮基线 `comm` 比对,**零新增**。 + +### 按红线 3 更新的既有断言(原值 / 新值 / 原因) + +| 文件 | 原值 | 新值 | 原因 | +|---|---|---|---| +| `design-token-contract` | `--color-action: #85432f` | `#a9583e` + `--color-action-strong: #cc785c`;另加 `--color-ring: var(--color-focus)` | 强调色分阶(BUG-738);ring 那条是**新增**约束,不是放宽 | +| `personal-report-theme-contract` | 标题 `font-weight: 400`,测试名称含「serif display stack」 | `500`,名称改为「display stack at the display weight」 | 那个「衬线栈」从未加载,实际是宋体;无衬线 400 在大字号发虚 | +| `consultation-entrypoint` | hero / 入口卡 `font-weight: 400` | `500` | 同上 | +| `site-style-isolation-contract` | `--font-body: StyreneB, var(--font-inter, Inter)` | 去掉 `StyreneB` | 死配置,从未加载 | +| `character-remaining-contract` | `` + `className="composer-footer"` + `.composer-footer > p { display: none; }` | 计数在 ChatComposer 内联;`doesNotMatch(page, /composer-footer/)`;`.composer-row` | 常驻 44px 横栏删除 | +| `session-conversation-layout` | `.composer-footer` 与 `.composer` 同宽 | `doesNotMatch(/\.composer-footer\s*\{/)` | 同上,**收紧**而非放宽 | +| `session-conversation-layout` | `.composer-stop` 背景 `--color-action` | `--color-action-strong` | 停止键是填充面,走填充阶 | +| `chat-stream-layout` | `grid-template-rows: 68px …` | `46px …` | 顶栏瘦身 | +| `rectification-agentic-entry` | `` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — | +| `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `
` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — | | `TASK-cend-surfaces-claude-alignment-20260916.md` | — | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | 执行中(Claude 直接执行) | — | ### 待开的任务书(2026-09-03 审查决定) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 35e0eadc..8009e06a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/src/app/admin/admin.css b/frontend/src/app/admin/admin.css index 422a7071..b3ae2cc1 100644 --- a/frontend/src/app/admin/admin.css +++ b/frontend/src/app/admin/admin.css @@ -1,5 +1,5 @@ :root { - --font-body: StyreneB, var(--font-inter, Inter), system-ui, sans-serif; + --font-body: var(--font-inter, Inter), system-ui, sans-serif; --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; } html, body { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1b125a01..e5c01376 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -13,8 +13,9 @@ --color-destructive: var(--color-danger); --color-border: #d8d6cf; --color-input: #b8b5ad; - --color-ring: #85432f; + --color-ring: var(--color-focus); --color-action: var(--color-action); + --color-action-strong: var(--color-action-strong); --color-action-soft: var(--color-action-soft); --color-action-hover: var(--color-action-hover); --color-action-on-dark: var(--color-action-on-dark); @@ -62,11 +63,18 @@ frontend/tests/viewport-breakpoint-contract.test.ts in the same change. */ :root { color-scheme: light; - --color-action: #85432f; - --color-action-soft: #f4e8e2; - --color-action-hover: #6f3627; - --color-focus: #85432f; - --color-action-on-dark: #b76f58; + /* Two ranks, because one hex cannot do both jobs. --color-action is the text + and icon rank: 53 of the 62 call sites are `color:`, including markdown links + inside answers, and Claude coral #cc785c only reaches 3.14:1 on the canvas. + #a9583e is that same coral darkened to 4.85:1. --color-action-strong is the + brand coral itself, spent on fills, rails, rings and borders, where 3:1 for + non-text UI applies. See BUG-738. */ + --color-action: #a9583e; + --color-action-strong: #cc785c; + --color-action-soft: #f7ece5; + --color-action-hover: #8f4630; + --color-focus: #cc785c; + --color-action-on-dark: #d78064; --color-ink: #1d1d1f; --color-ink-strong: #32322f; --color-ink-secondary: #5f5f59; @@ -97,8 +105,15 @@ --color-dark-border: rgba(251, 250, 247, .16); --shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2); --shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18); - --font-display: "Tiempos Headline", "Songti SC", STSong, "Noto Serif CJK SC", Georgia, serif; - --font-body: StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + /* Display and body share one sans stack. The old value led with two Anthropic + licensed faces (Tiempos Headline, StyreneB) that this app never loads — no + @font-face, nothing in public/, and layout.tsx vendors only Inter — so every + CJK heading fell through to Songti SC / SimSun. A Latin-serif-first stack was + measured and rejected: the Newsreader latin subset costs 132 KB to serif one + wordmark, and it splits mixed headings ("D10 事业盘怎么读") into two scripts. + Display rank now comes from weight and tracking, not family. See BUG-737. */ + --font-display: var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-body: var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; --type-display-lg: 48px; --type-display-md: 36px; @@ -126,7 +141,7 @@ --space-12: 48px; --space-16: 64px; --space-24: 96px; - --composer-reserve: 148px; + --composer-reserve: 124px; --ease-out: cubic-bezier(.22, 1, .36, 1); /* The personal report's paper surface. Declared here so the dark blocks can override it; see the note on .personal-report-reader. */ @@ -173,6 +188,7 @@ /* 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: #d78064; + --color-action-strong: #d78064; --color-action-soft: #3a2620; --color-action-hover: #e59273; --color-focus: #d78064; @@ -244,6 +260,7 @@ /* 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: #d78064; + --color-action-strong: #d78064; --color-action-soft: #3a2620; --color-action-hover: #e59273; --color-focus: #d78064; @@ -312,13 +329,13 @@ button:disabled { cursor: default; opacity: .45; } .app-loading-content { width: min(420px, 100%); display: grid; justify-items: center; gap: 8px; color: var(--color-ink-secondary); text-align: center; } .app-loading-symbol { position: relative; width: 56px; height: 56px; display: grid; place-items: center; } .app-loading-mark { width: 30px; height: 30px; background: url("/jyotish-logo.png") center / contain no-repeat; } -.app-loading-orbit::after { position: absolute; top: -3px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%; background: var(--color-action); box-shadow: 0 0 0 3px var(--color-canvas); content: ""; } +.app-loading-orbit::after { position: absolute; top: -3px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%; background: var(--color-action-strong); box-shadow: 0 0 0 3px var(--color-canvas); content: ""; } .app-loading-actions { display: flex; gap: var(--space-3); margin-top: var(--space-3); } .brand-mark, .welcome-mark, .auth-brand span { width: 28px; height: 28px; display: block; flex: 0 0 auto; background: url("/jyotish-logo.png") center / contain no-repeat; font-size: 0; } .session-nav { min-height: 0; flex: 0 0 auto; } .session-nav:last-of-type { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; } .chevron { width: 18px; height: 18px; color: var(--color-ink-tertiary); } -.chat-header strong, .chat-header span { display: block; } + .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; } @@ -336,7 +353,7 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-transition-fields { min-width: 0; display: grid; gap: var(--space-4); margin: 0; padding: 0; border: 0; } .birth-time-assessment-overlay { position: absolute; z-index: 2; inset: 0; display: grid; place-items: center; padding: var(--space-6); border-radius: inherit; background: var(--color-canvas-soft); } .birth-time-assessment-progress { width: min(360px, 100%); display: grid; justify-items: center; gap: var(--space-2); color: var(--color-ink-secondary); text-align: center; } -.birth-time-assessment-progress strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.birth-time-assessment-progress strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 500; } .birth-time-assessment-progress > span { font-size: var(--type-body-sm); line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } .onboarding-inline-error { margin-left: 0; } .welcome > .starter-list { margin-left: 0; } @@ -500,7 +517,7 @@ button:disabled { cursor: default; opacity: .45; } margin-top: 0; } .composer textarea::placeholder { color: var(--color-ink-tertiary); } -.composer button svg { width: 19px; height: 19px; } +.composer .composer-row-end > button svg { width: 18px; height: 18px; } .dialog-close svg { width: 19px; height: 19px; } .redeem-form { display: grid; gap: 8px; padding-top: 12px; } .redeem-form > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } @@ -570,13 +587,13 @@ button:disabled { cursor: default; opacity: .45; } .session-list { overflow-x: clip; } [data-sidebar="content"] { -webkit-overflow-scrolling: touch; } .chat-header > div { min-width: 0; flex: 1; } - .chat-header strong { max-width: 100%; } + .chat-header { padding: 0 var(--space-3); } + .chat-header-chart { display: none; } .onboarding-card { padding: 16px; } .message-assistant .message-content { max-width: 100%; } .message-user p { font-size: 14px; } .composer { min-height: 56px; } - .composer-footer > p { display: none; } .generated-section .section-title { align-items: stretch; flex-direction: column; } .generated-section .button-secondary { align-self: flex-start; } } @@ -628,7 +645,7 @@ button:disabled { cursor: default; opacity: .45; } } .app-loading { width: 100%; height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--color-canvas); } -.app-loading-content strong { margin-top: 10px; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; letter-spacing: -.3px; } +.app-loading-content strong { margin-top: 10px; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; letter-spacing: -.3px; } .app-loading-content > span { max-width: 360px; line-height: 1.55; font-size: var(--type-body-sm); } .app-loading-orbit { position: absolute; inset: 0; border: 1px solid var(--color-border); border-radius: 50%; animation: app-loading-orbit 1.4s linear infinite; border-color: var(--color-canvas-strong); } .app-loading-error { background: var(--color-canvas-soft); } @@ -822,7 +839,7 @@ button:disabled { cursor: default; opacity: .45; } .user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); } .user-avatar > svg { width: 100%; height: 100%; display: block; } .profile-avatar { width: 32px; height: 32px; } -.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; } +.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action-strong); color: var(--color-on-dark); font-weight: 500; } .profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; } [data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; } [data-sidebar="trigger"] svg { width: 18px; height: 18px; } @@ -849,12 +866,16 @@ button:disabled { cursor: default; opacity: .45; } [data-state="collapsed"] .sidebar-footer { padding-top: var(--space-3); } } -.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 68px minmax(0, 1fr) auto; } -.chat-panel.is-rectification { grid-template-rows: 68px minmax(0, 1fr); } -.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; } +.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; } +.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); } +.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; } .chat-header > div { min-width: 0; } -.chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; } -.chat-header-subtitle { max-width: min(560px, 62vw); overflow: hidden; color: var(--color-muted-foreground); font-size: var(--type-caption); line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +/* One line, 46px tall. The former second line read `分析对象:…`; the chart it + analyses is now a quiet chip beside the title, and drops out on narrow + viewports rather than wrapping the header to two rows. */ +.chat-header-title { min-width: 0; display: flex; align-items: baseline; gap: var(--space-3); } +.chat-header strong { min-width: 0; overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-md); font-weight: 500; letter-spacing: -.2px; } +.chat-header-chart { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; } .chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; } .chat-header-actions > * { flex: 0 0 auto; } .credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; } @@ -867,7 +888,7 @@ button:disabled { cursor: default; opacity: .45; } max-width: 680px; font-family: var(--font-display); font-size: 32px; - font-weight: 400; + font-weight: 500; letter-spacing: -.5px; line-height: 1.28; text-wrap: balance; @@ -875,10 +896,10 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-message { padding: 0 0 var(--space-3); } .onboarding-message .message-markdown p { word-break: auto-phrase; text-wrap: pretty; } .onboarding-message.is-phrase-safe .message-markdown p { overflow-wrap: break-word; } -.onboarding-stream .message-markdown p:last-child::after { width: 2px; height: 1em; display: inline-block; margin-left: 3px; background: var(--color-action); vertical-align: -.12em; animation: onboarding-caret 700ms steps(1, end) infinite; content: ""; } +.onboarding-stream .message-markdown p:last-child::after { width: 2px; height: 1em; display: inline-block; margin-left: 3px; background: var(--color-action-strong); vertical-align: -.12em; animation: onboarding-caret 700ms steps(1, end) infinite; content: ""; } .onboarding-stream.is-complete .message-markdown p:last-child::after { content: none; } .onboarding-card { border: 1px solid var(--color-border); max-width: 680px; margin: var(--space-2) 0 var(--space-5); padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } -.onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 500; } .onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); } .birth-time-intake { display: grid; gap: var(--space-4); } .birth-time-profile-result { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--color-success) 32%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-success) 5%, var(--color-canvas)); } @@ -912,7 +933,7 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } .birth-time-assessment-heading > div { display: grid; gap: var(--space-1); } .birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; } -.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; } +.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; } .birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: var(--type-overline) !important; letter-spacing: .04em !important; } .birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } .birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); } @@ -1002,7 +1023,7 @@ button:disabled { cursor: default; opacity: .45; } .conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); } .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } .conversational-evidence-recap > header, .conversational-candidate > header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } -.conversational-evidence-recap h3, .conversational-candidate h3, .conversational-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.conversational-evidence-recap h3, .conversational-candidate h3, .conversational-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 500; } .conversational-evidence-recap > header span, .conversational-candidate > header span { color: var(--color-ink-secondary); font-size: var(--type-caption); } .conversational-candidate > header .is-unverified { color: var(--color-warning); font-weight: 600; } .conversational-candidate > header .is-confirmed { color: var(--color-success); font-weight: 600; } @@ -1388,7 +1409,7 @@ button:disabled { cursor: default; opacity: .45; } .message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; } .message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); line-height: 1.5; } .message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); } -.message-markdown h2, .message-markdown h3 { margin: 24px 0 10px; color: var(--color-ink); font-family: var(--font-display); font-weight: 400; letter-spacing: -.3px; } +.message-markdown h2, .message-markdown h3 { margin: 24px 0 10px; color: var(--color-ink); font-family: var(--font-display); font-weight: 500; letter-spacing: -.3px; } .message-markdown h2 { font-size: var(--type-display-sm); } .message-markdown h3 { font-size: var(--type-title-lg); } .message-markdown strong { color: var(--color-ink); font-weight: 500; } @@ -1658,15 +1679,19 @@ button:disabled { cursor: default; opacity: .45; } outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } -.composer { width: min(760px, 100%); display: flex; align-items: flex-end; gap: var(--space-3); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; min-height: 60px; padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas); } +.composer { width: min(760px, 100%); display: flex; flex-direction: column; gap: var(--space-2); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; padding: var(--space-3) var(--space-3) var(--space-2); border-radius: var(--radius-lg); background: var(--color-canvas); } +/* The composer's own bottom row. It replaces .composer-footer, a strip below + the composer that held a permanent 44px whether or not it had anything to + show. The row only exists when there is a toolbar or a count to render. */ +.composer-row { min-height: 36px; display: flex; align-items: center; gap: var(--space-2); } +.composer-tools { min-width: 0; display: flex; align-items: center; gap: var(--space-2); } +.composer-row-end { display: flex; flex: 1; align-items: center; justify-content: flex-end; gap: var(--space-3); } .composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); } -.composer textarea { min-width: 0; min-height: 44px; max-height: 128px; flex: 1; resize: none; padding: var(--space-3) 0 var(--space-2); border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: var(--type-body-md); } -.composer button { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: 999px; background: var(--color-action); } -.composer .composer-stop { background: var(--color-action); } +.composer textarea { width: 100%; min-width: 0; min-height: 28px; max-height: 148px; resize: none; padding: var(--space-1) 0 0; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: var(--type-body-md); } +.composer .composer-row-end > button { width: 36px; height: 36px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: 999px; background: var(--color-action-strong); } +.composer .composer-stop { background: var(--color-action-strong); } .composer .composer-stop:not(:disabled):hover { background: var(--color-action-hover); } -.composer-footer { width: min(760px, 100%); min-height: 44px; display: flex; align-items: center; margin: var(--space-1) auto 0; } .character-remaining { - margin-left: auto; flex-shrink: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); @@ -1689,7 +1714,7 @@ button:disabled { cursor: default; opacity: .45; } .model-selector-positioner { z-index: 30; outline: 0; } .model-selector-popup { width: max-content; min-width: 180px; max-width: min(420px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } .model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); } -.model-selector-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 400; } +.model-selector-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 500; } .model-selector-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; padding: 0; border: 0; } .model-selector-option { width: 100%; min-height: 48px; display: grid; grid-template-columns: auto 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); color: var(--color-ink-secondary); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; } .model-selector-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); } @@ -1709,7 +1734,7 @@ button:disabled { cursor: default; opacity: .45; } .account-menu-popup { width: min(280px, calc(100vw - var(--space-6))); max-height: var(--available-height); overflow-y: auto; padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); color: var(--sidebar-foreground); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); } .account-menu-identity { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); margin-bottom: var(--space-1); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-muted); } -.account-menu-avatar { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; } +.account-menu-avatar { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action-strong); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; } .user-avatar.account-menu-avatar { overflow: hidden; } .account-menu-identity > span:last-child { min-width: 0; } .account-menu-identity b, .account-menu-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -1780,7 +1805,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 { 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: 500; 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); } @@ -1799,7 +1824,7 @@ button:disabled { cursor: default; opacity: .45; } .logout-copy { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.6; text-wrap: pretty; } .dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); } .sheet-section { border-bottom: 1px solid var(--color-border); padding: var(--space-6) 0; border-color: var(--color-border); } -.section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 500; } .section-toggle small, .section-heading small { display: block; margin-top: 4px; color: var(--color-ink-secondary); font-weight: 400; font-size: var(--type-caption); } .default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .default-chart-card > div, .chart-library-item > div:first-child, .chart-library-actions { min-width: 0; } @@ -1841,7 +1866,7 @@ button.chart-library-item { cursor: pointer; text-align: left; } input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: var(--type-body-md); } input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); } .button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 var(--space-4); border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: var(--type-body-sm); font-weight: 500; } -.button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); } +.button-primary { border-color: var(--color-action-strong); background: var(--color-action-strong); color: var(--color-on-dark); } .button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); } .form-error, .form-success { margin: var(--space-3) 0 0; padding: var(--space-3); border-left: 3px solid currentColor; line-height: 1.5; border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: var(--type-caption); } .danger-primary { border-color: var(--color-danger); background: var(--color-danger); } @@ -1851,7 +1876,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .auth-story { display: flex; flex-direction: column; justify-content: space-between; padding: var(--space-12); border-right: 1px solid var(--color-border); background: var(--color-canvas-muted); color: var(--color-ink); } .auth-story-brand, .auth-brand { display: flex; align-items: center; gap: var(--space-3); font-family: var(--font-display); font-size: var(--type-title-lg); } .auth-story-brand strong, .auth-brand strong { font-weight: 400; } -.auth-story h2 { max-width: 500px; margin: var(--space-4) 0 var(--space-5); font-family: var(--font-display); font-size: var(--type-display-lg); font-weight: 400; letter-spacing: -1px; line-height: 1.15; text-wrap: balance; } +.auth-story h2 { max-width: 500px; margin: var(--space-4) 0 var(--space-5); font-family: var(--font-display); font-size: var(--type-display-lg); font-weight: 500; letter-spacing: -1px; line-height: 1.15; text-wrap: balance; } .auth-story > div > p { max-width: 480px; margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; } .auth-footnote { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } .auth-panel { width: 100%; min-width: 0; display: flex; flex-direction: column; justify-content: center; padding: var(--space-12); border: 0; border-radius: 0; background: var(--color-canvas); } @@ -1882,7 +1907,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .admin-app-shell { height: 100vh; min-height: 0; overflow-y: auto; } .admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; } .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 h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; 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 { font-family: var(--font-mono); } @@ -1935,7 +1960,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); } .message-content { max-width: 88%; } .composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); } - :root { --composer-reserve: 116px; } + :root { --composer-reserve: 100px; } .select-content { max-height: min(320px, 56dvh); } .select-list { max-height: min(288px, 48dvh) !important; } .account-modal-overlay { padding: 0; } @@ -2043,7 +2068,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: clamp(34px, 5vw, 52px); - font-weight: 400; + font-weight: 500; letter-spacing: -.04em; line-height: 1.08; text-wrap: balance; @@ -2161,7 +2186,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: clamp(24px, 2.4vw, 31px); - font-weight: 400; + font-weight: 500; letter-spacing: -.03em; line-height: 1.12; } @@ -2224,8 +2249,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class transition: color 180ms ease-out, transform 180ms var(--ease-out); } -.composer-wrap-starter .composer, -.composer-wrap-starter .composer-footer { +.composer-wrap-starter .composer { width: min(1040px, 100%); margin-inline: auto; } @@ -2414,8 +2438,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-top-color: color-mix(in srgb, var(--color-border) 54%, transparent); } -.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer, -.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer-footer { +.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer { width: min(var(--session-column-width), 100%); } @@ -2518,7 +2541,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .birth-time-source-option:hover { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); } .birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action); } .birth-time-source-option input { appearance: none; align-self: center; justify-self: center; position: static; box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 16px; min-height: 16px; max-height: 16px; margin: 0; overflow: visible; clip: auto; clip-path: none; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); } -.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action); box-shadow: inset 0 0 0 4px var(--color-action-soft); } +.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action-strong); box-shadow: inset 0 0 0 4px var(--color-action-soft); } .birth-time-source-option > span { gap: 4px; } .birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; } .birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; } @@ -2736,7 +2759,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class height: 9px; border-radius: 50%; border: 1px solid var(--color-action); - background: var(--color-action); + background: var(--color-action-strong); transition: background 260ms ease, border-color 260ms ease, opacity 260ms ease; } .rectification-timeline__mark[data-state="out"] { @@ -3556,7 +3579,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-display-lg); - font-weight: 400; + font-weight: 500; letter-spacing: -1px; line-height: 1.1; text-wrap: balance; @@ -3587,7 +3610,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); - font-weight: 400; + font-weight: 500; letter-spacing: -.3px; } .report-center-section-heading button { @@ -3686,7 +3709,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; } .report-center-empty p { max-width: 460px; @@ -3716,7 +3739,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; letter-spacing: -.3px; } .report-center-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); } @@ -3805,7 +3828,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: var(--space-8) 0 var(--space-3); font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; } .personal-report-md-article h3 { margin: var(--space-6) 0 var(--space-2); @@ -3915,7 +3938,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; letter-spacing: -.3px; text-wrap: balance; } @@ -3934,7 +3957,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; letter-spacing: -.3px; font-variant-numeric: tabular-nums; } @@ -4019,7 +4042,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-display-lg); - font-weight: 400; + font-weight: 500; letter-spacing: -1px; line-height: 1.1; text-wrap: balance; @@ -4053,7 +4076,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; } @@ -4064,7 +4087,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; letter-spacing: -.3px; line-height: 1.2; text-wrap: balance; @@ -4120,7 +4143,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); - font-weight: 400; + font-weight: 500; line-height: 1.3; } .personal-report-subheading span { color: var(--color-ink-secondary); font-size: var(--type-caption); } @@ -4276,7 +4299,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: var(--space-6) 0 var(--space-3); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; } .personal-report-audit-table { font-size: var(--type-caption); } .personal-report-audit-table th:nth-child(1) { width: 24%; } @@ -4307,7 +4330,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0 0 var(--space-2); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; } .personal-report-disclaimer p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.75; } .personal-report-footer { @@ -4365,9 +4388,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class --color-canvas-muted: #ebe9e3; --color-border: #d8d6cf; --color-border-strong: #b8b5ad; - --color-action: #85432f; - --color-action-hover: #6f3627; - --color-focus: #85432f; + --color-action: #a9583e; + --color-action-strong: #cc785c; + --color-action-hover: #8f4630; + --color-focus: #cc785c; --color-danger: #9a2f2f; --color-success: #28633e; --color-warning: #b07b22; @@ -4584,7 +4608,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-display-lg); - font-weight: 400; + font-weight: 500; letter-spacing: -1px; line-height: 1.1; text-wrap: balance; @@ -4742,7 +4766,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0 0 var(--space-2); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; } .chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); } .chart-page-symbol { color: var(--color-ink-tertiary); } @@ -4903,7 +4927,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-display-lg); - font-weight: 400; + font-weight: 500; letter-spacing: -1px; line-height: 1.1; text-wrap: balance; @@ -4933,7 +4957,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); - font-weight: 400; + font-weight: 500; } .ephemeris-static, .ephemeris-note { @@ -5060,7 +5084,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-family: var(--font-display); font-size: var(--type-display-sm); - font-weight: 400; + font-weight: 500; } .ephemeris-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c930b8a3..f5823076 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -36,7 +36,6 @@ import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; import { ChatComposer } from "@/components/chat-composer"; import { JumpToLatestButton } from "@/components/jump-to-latest-button"; -import { ComposerCharacterRemaining } from "@/components/composer-character-remaining"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; @@ -1622,9 +1621,9 @@ export default function Home() {
-
+
{activeSession?.title || "新对话"} - 分析对象:{activeSession ? sessionChartLabel(activeSession, chartLibrary) : "未关联资料"} + {activeSession ? {sessionChartLabel(activeSession, chartLibrary)} : null}
@@ -1810,17 +1809,16 @@ export default function Home() { }} onKeyDown={handleComposerKeyDown} queued={queuedDraft ? { text: queuedDraft, onRecall: recallQueuedDraft } : undefined} + toolbar={( + void selectSessionModel(modelId)} + /> + )} onStop={() => void stopResponse()} /> -
- void selectSessionModel(modelId)} - /> - -
} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 7833928b..cf4428e8 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -51,7 +51,7 @@ export function AdminApp({ children }: { children: ReactNode }) { borderRadiusLG: 8, boxShadowSecondary: "0 1px 2px rgba(29, 29, 31, .06)", boxShadowTertiary: "none", - fontFamily: "StyreneB, var(--font-inter, Inter), system-ui, sans-serif", + fontFamily: "var(--font-inter, Inter), system-ui, sans-serif", }, components: { Layout: { diff --git a/frontend/src/components/chat-composer.tsx b/frontend/src/components/chat-composer.tsx index e125e4dc..d0a97f18 100644 --- a/frontend/src/components/chat-composer.tsx +++ b/frontend/src/components/chat-composer.tsx @@ -1,10 +1,11 @@ "use client"; import { useSyncExternalStore } from "react"; -import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react"; +import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react"; import { ArrowUp, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { CharacterRemaining } from "@/components/character-remaining"; import { characterRemainingVisible } from "@/lib/character-remaining"; import { composerDraftSnapshot, @@ -43,6 +44,12 @@ type ChatComposerProps = { readonly stopLabel: string; readonly stopTitle: string; readonly queued?: { text: string; onRecall: () => void }; + /** + * Controls that belong to the composer itself (model picker, chart subject). + * They render on the composer's own bottom row; there is no longer a strip + * below the composer holding a permanent 44px of chrome. + */ + readonly toolbar?: ReactNode; readonly onSubmit: (event: FormEvent) => void; readonly onChange: (event: ChangeEvent) => void; readonly onKeyDown: (event: KeyboardEvent) => void; @@ -64,6 +71,7 @@ export function ChatComposer({ stopLabel, stopTitle, queued, + toolbar, onSubmit, onChange, onKeyDown, @@ -100,22 +108,30 @@ export function ChatComposer({ onChange={onChange} onKeyDown={onKeyDown} /> - {stopVisible ? ( - - ) : ( - - )} +
+ {toolbar ?
{toolbar}
: null} +
+ {showRemaining ? ( + + ) : null} + {stopVisible ? ( + + ) : ( + + )} +
+
); diff --git a/frontend/src/components/composer-character-remaining.tsx b/frontend/src/components/composer-character-remaining.tsx deleted file mode 100644 index 298f43ae..00000000 --- a/frontend/src/components/composer-character-remaining.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -import { CharacterRemaining } from "@/components/character-remaining"; -import { composerRemainingId, useComposerDraft } from "@/components/chat-composer"; - -export function ComposerCharacterRemaining({ maxLength }: { maxLength: number }) { - const draft = useComposerDraft(); - return ; -} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 8d3bb6d5..aaf43767 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -100,7 +100,6 @@ import { isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type import type { PublicLanguageModel } from "@/lib/public-models"; import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor"; import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; -import { CharacterRemaining } from "./character-remaining"; import { ChatComposer } from "./chat-composer"; import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; import { JumpToLatestButton } from "./jump-to-latest-button"; @@ -1828,22 +1827,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { event.currentTarget.form?.requestSubmit(); } }} + toolbar={( + + )} onStop={stopRun} /> - -
- - -
/); - assert.match(page, /className="composer-footer"/); + // 原值:page.tsx 里的 `` 加 `.composer-footer` 常驻横栏 + // 新值:计数由 ChatComposer 在自己的底排内联渲染,只在 showRemaining 为真时进 DOM + // 原因:`.composer-footer` 的 min-height 是常驻 44px,没到字数上限时那一条是空的。 + assert.match(composer, /\{showRemaining \? \(\s*/); + assert.doesNotMatch(page, /composer-footer/); assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/); assert.match(guide, /aria-describedby=\{describedBy\}/); assert.match(choice, /aria-describedby=\{characterRemainingVisible\(note\.length, 240\) \? unmatchedRemainingId : undefined\}/); - assert.match(css, /\.composer-footer > p \{ display: none; \}/); + // 原值 `.composer-footer > p { display: none; }`(窄屏藏计数)/ 新值:`.composer-row` 无内容时不渲染 + // / 原因:计数不再有常驻容器,窄屏不需要再单独藏它。 + assert.match(css, /\.composer-row \{[^}]*display: flex/); assert.doesNotMatch(page, /

\{/); assert.match(page, /chartSnapshotForSession/); assert.match(page, /chart_profile_name: session\.chartProfileName/); - assert.match(page, /分析对象:/); + // 原值 `分析对象:{...}` 副标题 / 新值 `.chat-header-chart` 静默 chip + // / 原因:顶栏从 68px 收到 46px 单行;「分析对象」是内部术语,标签去掉只留盘名。 + // 会话与星盘资料的绑定关系本身没变,下面两条仍然锁住它。 + assert.match(page, /className="chat-header-chart"/); + assert.match(page, /sessionChartLabel\(activeSession, chartLibrary\)/); assert.match(migration, /add column if not exists chart_profile_id text/); assert.match(migration, /grant insert \(chart_profile_id, chart_profile_name, chart_profile_role\)/); assert.match(migration, /grant update \(chart_profile_id, chart_profile_name, chart_profile_role\)/); diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index dfb12044..348ad975 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -164,7 +164,7 @@ test("nothing sits between the transcript and the composer to shift height while test("docks the composer inside the chat panel instead of floating over content", () => { assert.match(pageSource, /

/); - assert.match(globalStyles, /\.chat-panel[^}]*grid-template-rows:\s*68px minmax\(0,\s*1fr\) auto/); + assert.match(globalStyles, /\.chat-panel[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/); assert.match(globalStyles, /\.conversation[^}]*padding-bottom:\s*var\(--composer-reserve\)/); assert.match(globalStyles, /\.composer-wrap[^}]*position:\s*sticky/); assert.match(globalStyles, /\.composer-wrap[^}]*bottom:\s*0/); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 877a0f3a..801a8db5 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -425,10 +425,12 @@ test("starter homepage keeps its editorial display typography", () => { const entrypointHeading = styles.match(/\.product-entrypoint-copy h2\s*\{([^}]*)\}/)?.[1] ?? ""; assert.match(heroHeading, /font-family:\s*var\(--font-display\)\s*;/); - assert.match(heroHeading, /font-weight:\s*400\s*;/); + // 原值 400 / 新值 500 / 原因:--font-display 从(从未加载的)衬线栈改为无衬线, + // 400 在 clamp(34px,5vw,52px) 上发虚,层级改由字重承担。见 BUG-737。 + assert.match(heroHeading, /font-weight:\s*500\s*;/); assert.doesNotMatch(heroHeading, /font-family:\s*var\(--font-body\)/); assert.match(entrypointHeading, /font-family:\s*var\(--font-display\)\s*;/); - assert.match(entrypointHeading, /font-weight:\s*400\s*;/); + assert.match(entrypointHeading, /font-weight:\s*500\s*;/); assert.doesNotMatch(entrypointHeading, /font-family:\s*var\(--font-body\)/); }); diff --git a/frontend/tests/design-token-contract.test.ts b/frontend/tests/design-token-contract.test.ts index 7dbb854d..36b3625b 100644 --- a/frontend/tests/design-token-contract.test.ts +++ b/frontend/tests/design-token-contract.test.ts @@ -118,7 +118,12 @@ test("@theme resolves palette aliases inline so the :root values stay authoritat // Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat // layered ones, so the literal values below must stay outside any layer. assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette"); - assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m); + // 原值 `--color-action: #85432f` / 新值 `--color-action: #a9583e` 加 `--color-action-strong: #cc785c` + // / 原因:产品拍板换 Claude coral,但 #cc785c 作文字色在画布上只有 3.14:1,而 53 个调用点是 + // `color:`(含回答里的 markdown 链接)。拆成文字阶(4.85:1)与填充阶(品牌 coral)。见 BUG-738。 + assert.match(css, /^:root \{\n color-scheme: light;\n(?: \/\*[\s\S]*?\*\/\n)? --color-action: #a9583e;\n --color-action-strong: #cc785c;/m); + // `--color-ring` 曾在 @theme inline 里硬编码 #85432f,不跟随 :root,改色时最易漏。 + assert.match(css, /--color-ring: var\(--color-focus\);/); }); test("@theme literals do not drift from the :root token they duplicate", () => { diff --git a/frontend/tests/font-stack-loadable-contract.test.ts b/frontend/tests/font-stack-loadable-contract.test.ts new file mode 100644 index 00000000..799f7444 --- /dev/null +++ b/frontend/tests/font-stack-loadable-contract.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * BUG-737: `--font-display` led with "Tiempos Headline" and `--font-body` with + * StyreneB. Both are Anthropic licensed faces this app has never loaded — no + * @font-face, nothing in public/, and layout.tsx vendors only Inter — so every + * CJK heading fell through to the next entry, Songti SC / SimSun, for the life + * of the product. Nothing failed, because nothing checked that a declared family + * can actually resolve. + * + * This contract closes that: every quoted family in the two stacks must either + * be vendored through next/font/local, or be a face the OS is known to ship. + */ + +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); + +/** Faces that ship with a target OS, so naming them costs no request. */ +const SYSTEM_FACES = new Set([ + // Apple + "PingFang SC", + "Helvetica Neue", + // Windows + "Microsoft YaHei", + "Segoe UI", + // Cross-platform fallbacks that are generic families or UA keywords. + "Georgia", + "Consolas", + "SFMono-Regular", + "JetBrains Mono", +]); + +function stackValue(token: string): string { + const match = css.match(new RegExp(`^\\s*${token}:\\s*([^;]+);`, "m")); + assert.ok(match, `${token} must be declared in globals.css`); + return match![1]; +} + +function quotedFamilies(stack: string): string[] { + return [...stack.matchAll(/"([^"]+)"/g)].map((m) => m[1]); +} + +/** Families vendored via next/font/local, keyed by the CSS variable they expose. */ +function vendoredVariables(): string[] { + return [...layout.matchAll(/variable:\s*"(--[a-z0-9-]+)"/g)].map((m) => m[1]); +} + +test("every quoted family in the UI font stacks is either vendored or a system face", () => { + for (const token of ["--font-display", "--font-body"]) { + const stack = stackValue(token); + for (const family of quotedFamilies(stack)) { + assert.ok( + SYSTEM_FACES.has(family), + `${token} names "${family}", which is neither vendored via next/font/local nor a known system face. ` + + "A family that cannot load is dead configuration: it makes the stack look intentional while the " + + "browser silently falls through to the next entry. Vendor it into src/app/fonts/ with an OFL/SIL " + + "licence file beside it, or drop the name. See BUG-737.", + ); + } + } +}); + +test("the UI font stacks resolve through the vendored Inter variable", () => { + const vendored = vendoredVariables(); + assert.ok(vendored.includes("--font-inter"), "layout.tsx must vendor Inter through next/font/local"); + for (const token of ["--font-display", "--font-body"]) { + assert.match( + stackValue(token), + /var\(--font-inter, Inter\)/, + `${token} must route through the vendored Inter variable rather than naming a face it cannot load`, + ); + } +}); + +test("no CJK serif is reachable from the display stack", () => { + // The fix is not "pick a different serif" — it is that Chinese text never + // takes a serif here. A Latin-serif-first stack was measured and rejected: + // Newsreader's latin subset costs 132 KB to serif one wordmark, and it splits + // a mixed heading such as "D10 事业盘怎么读" into two scripts. + const display = stackValue("--font-display"); + for (const banned of ["Songti", "STSong", "SimSun", "Noto Serif", "Source Han Serif"]) { + assert.doesNotMatch( + display, + new RegExp(banned.replace(/ /g, "\\s")), + `--font-display must not reach "${banned}"; CJK headings rendered in 宋体 is exactly BUG-737`, + ); + } + // The generic `serif` keyword too — but not the `sans-serif` it is a suffix of. + assert.doesNotMatch( + display, + /(^|[\s,])serif\b/, + "--font-display must not end in the generic serif family; the UA default for CJK there is 宋体", + ); +}); + +test("display rank comes from weight, not from a second family", () => { + // With one shared stack, every rule that reaches for --font-display has to + // carry its own weight, or it renders at body rank and the hierarchy is gone. + const displayRules = [...css.matchAll(/font-family:\s*var\(--font-display\)/g)]; + assert.ok(displayRules.length > 10, "sanity: the display token should still be in wide use"); + assert.doesNotMatch( + css, + /font-family:\s*var\(--font-display\);\s*font-size:[^;]*;\s*font-weight:\s*400\s*;/, + "a --font-display rule still sits at weight 400; with a sans stack that is body rank", + ); +}); diff --git a/frontend/tests/personal-report-theme-contract.test.ts b/frontend/tests/personal-report-theme-contract.test.ts index d8f682ce..7856ee24 100644 --- a/frontend/tests/personal-report-theme-contract.test.ts +++ b/frontend/tests/personal-report-theme-contract.test.ts @@ -72,13 +72,17 @@ test("print pins the palette back to light so paper is always paper", () => { assert.equal(printRoot.get("report-rule"), "#d8d6cf"); }); -test("report titles use the product serif display stack at weight 400", () => { +// 原值:断言标题用 --font-display 且 font-weight: 400(当时 --font-display 是衬线栈) +// 新值:仍用 --font-display,但字重 500 +// 原因:BUG-737——那个"衬线栈"里的 Tiempos Headline 从未加载,中文标题实际落到宋体。 +// --font-display 现在是无衬线,400 在大字号上发虚,display 层级改由字重承担。 +test("report titles use the display stack at the display weight", () => { const centreTitle = cssDeclarations(".report-center-hero h1"); const coverTitle = cssDeclarations(".personal-report-cover h1"); const sectionTitle = cssDeclarations(".personal-report-section-heading h2"); for (const [name, body] of [["centre", centreTitle], ["cover", coverTitle], ["section", sectionTitle]] as const) { assert.match(body, /font-family:\s*var\(--font-display\)/, `${name} title must use --font-display`); - assert.match(body, /font-weight:\s*400/, `${name} title must stay weight 400`); + assert.match(body, /font-weight:\s*500/, `${name} title must stay weight 500`); } }); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 9a06167e..77ab944d 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -739,7 +739,10 @@ test("rectification keeps the composer but never renders generated suggestion ch assert.match(chat, / / 新值:计数由 ChatComposer 内联渲染 + // / 原因:`.composer-footer` 常驻横栏已删,两个调用方都改用 toolbar 插槽。 + assert.match(chat, /toolbar=\{\(/); + assert.doesNotMatch(chat, /composer-footer/); assert.doesNotMatch(chat, /composer-suggestions/); assert.doesNotMatch(chat, /setSuggestions/); assert.doesNotMatch(chat, /suggestions/); diff --git a/frontend/tests/session-conversation-layout.test.ts b/frontend/tests/session-conversation-layout.test.ts index 8f24c584..e2a05f00 100644 --- a/frontend/tests/session-conversation-layout.test.ts +++ b/frontend/tests/session-conversation-layout.test.ts @@ -10,7 +10,9 @@ test("aligns the session transcript and composer to one readable column", () => assert.match(globalStyles, /--session-column-width:\s*760px/); assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \.message-list\s*\{[^}]*width:\s*min\(calc\(var\(--session-column-width\)/); assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \+ \.composer-wrap \.composer[^\{]*\{[^}]*width:\s*min\(var\(--session-column-width\),\s*100%\)/); - assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \+ \.composer-wrap \.composer-footer[^\{]*\{[^}]*width:\s*min\(var\(--session-column-width\),\s*100%\)/); + // 原值:`.composer-footer` 与 `.composer` 同宽 / 新值:只剩 `.composer`(上一条已断言) + // / 原因:常驻底栏已删,工具行移进输入框内部,不再有第二个需要对齐的盒子。 + assert.doesNotMatch(globalStyles, /\.composer-footer\s*\{/); }); test("keeps onboarding transcript and intake card on the same session column", () => { @@ -47,6 +49,8 @@ test("lets model controls fit their labels and keeps the stop action on-brand", assert.match(globalStyles, /\.model-selector-popup\s*\{[^}]*width:\s*max-content[^}]*min-width:\s*180px[^}]*max-width:\s*min\(420px,/); assert.match(globalStyles, /\.model-selector-copy b\s*\{[^}]*overflow:\s*visible[^}]*white-space:\s*normal/); assert.doesNotMatch(globalStyles, /\.model-selector-copy b\s*\{[^}]*text-overflow:\s*ellipsis/); - assert.match(globalStyles, /\.composer \.composer-stop\s*\{[^}]*background:\s*var\(--color-action\)/); + // 原值 `var(--color-action)` / 新值 `var(--color-action-strong)` / 原因:停止键是填充面, + // 走品牌 coral 那一阶;文字阶留给 --color-action。见 BUG-738。 + assert.match(globalStyles, /\.composer \.composer-stop\s*\{[^}]*background:\s*var\(--color-action-strong\)/); assert.match(globalStyles, /\.composer \.composer-stop:not\(:disabled\):hover\s*\{[^}]*background:\s*var\(--color-action-hover\)/); }); diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts index 3905d78a..6ba3fb51 100644 --- a/frontend/tests/site-style-isolation-contract.test.ts +++ b/frontend/tests/site-style-isolation-contract.test.ts @@ -65,7 +65,10 @@ test("site chrome owns globals.css; admin does not import it", () => { readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"), /site-styles|globals\.css/, ); - assert.match(adminCss, /--font-body: StyreneB, var\(--font-inter, Inter\)/); + assert.match(adminCss, // 原值 `--font-body: StyreneB, var(--font-inter, Inter)` / 新值:去掉 StyreneB + // 原因:StyreneB 是 Anthropic 授权字体,本仓从未加载它(无 @font-face、public/ 无字体文件), + // 留在栈首只是死配置。见 BUG-737。 + /--font-body: var\(--font-inter, Inter\)/); }); test("root error and not-found pages do not depend on Tailwind utilities or Button", () => {