From 62903b7a12aede959a9f7ea1e7614dd42598133c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 05:31:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E7=A9=BA=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=94=B9=E6=88=90=E3=80=8C=E9=97=AE=E5=80=99=20+=20=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A1=86=20+=20=E4=B8=A4=E6=9E=9A=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=20pill=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前空态是 hero 卡(h1 clamp 到 52px)加两张 min-height 228px 的产品卡, 输入框被压在约 800px 内容之下,主动作不是视觉焦点。 改法:问候拆成 StarterGreeting 留在对话区并底对齐,两个产品入口降级成 StarterEntries 渲染在 composer 之后。composer 本身一行未动——它在空态与 会话态是同一个 DOM 位置,首条消息落地时不重挂载,草稿/焦点/排队卡都不受影响。 顺带修掉:空态 composer 1040px、会话态 760px,发首条消息时输入框会当场 变窄一截。两态统一到 --session-column-width,并新增断言锁住。 校正入口保留三个服务端驱动的文案分支(我第一版收成了两个,是 rectification-agentic-entry 打红才发现)。 DESIGN.md:Product entrypoint card 整节重写为 Starter home;删掉 Suggestion card 死文档(它描述的主题卡样式正是本轮删除的 CSS)。 测试 3350 不变,fail 仍 31 且与基线双向零差异;/ 仍 Static; CSS gzip 41,096→41,044(−0.13%)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- CHANGELOG.md | 10 + docs/tasks/PROGRESS-cend-ui-r2-20260916.md | 137 ++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 59 ++- frontend/src/app/globals.css | 397 +++++------------- frontend/src/app/page.tsx | 42 +- frontend/src/components/starter-home.tsx | 152 ++++--- frontend/tests/chat-reading-load-home.test.ts | 27 +- .../class-name-definition-contract.test.ts | 1 - .../tests/consultation-entrypoint.test.ts | 133 +++--- .../tests/onboarding-presentation.test.ts | 6 +- .../rectification-surface-contract.test.ts | 4 +- .../tests/session-conversation-layout.test.ts | 9 +- 13 files changed, 486 insertions(+), 493 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-ui-r2-20260916.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adafbb6..7c8d7557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 首页改成「一句问候,下面就是输入框」 + +打开首页,此前最先看到的是一张大标题卡和两张产品卡(今日星语、生时校正),输入框被压在它们下面,要往下看才找得到。现在顺序反过来:一句问候,紧接着就是输入框,两个入口降级成输入框下方的两枚小按钮,它们的说明合并成一行浅色提示。 + +顺带修掉一个一直存在的小毛病:首页的输入框此前比会话里的输入框宽(1040 对 760 像素),发出第一条消息的瞬间它会当场变窄一截。现在两处同宽,不再跳动。 + +「今日星语」卡片上原本那行"行动建议"在小按钮上放不下,已经去掉——点进去之后的正文里仍然完整。 + +Skill 版本不变(纯界面改动)。 + ## 2026-09-16 — 界面标题不再是宋体,强调色换成暖珊瑚,输入框下方那条空栏删掉 **标题字体。** 全站的标题——品牌字、对话标题、首页大标题、**回答正文里的小标题**、各种弹窗标题——此前在真实设备上都是以宋体渲染的:苹果机型落到「Songti SC」,Windows 落到「SimSun」。原因是字体栈里排在最前面的两个字体本仓从来没有加载过,浏览器一路往后掉,掉到了中文衬线上。宋体是很好的印刷体,但在屏幕上做小字号界面标题时笔画偏细、发虚,和正文字重也对不上。现在标题和正文用同一套无衬线字体,标题靠**字重**区分层级。 diff --git a/docs/tasks/PROGRESS-cend-ui-r2-20260916.md b/docs/tasks/PROGRESS-cend-ui-r2-20260916.md new file mode 100644 index 00000000..fa6e9013 --- /dev/null +++ b/docs/tasks/PROGRESS-cend-ui-r2-20260916.md @@ -0,0 +1,137 @@ +# PROGRESS · C 端界面对齐 R2(空状态重做) + +任务书:`docs/tasks/TASK-cend-ui-claude-alignment-20260916.md` 的 R2 +执行方式:**直接执行模式** +分支:`codex/cend-ui-r2-20260916` +基线:`origin/staging` = `66d9f59d`(R1 合入后) +BUG 段:不占用(产品改造,非缺陷) + +--- + +## 一处与任务书不符的现状,先说明 + +任务书 E5 写的是「hero 卡 + 两张入口大卡 + 『从一个主题开始』标题 + 3 列主题卡」。实际在 `66d9f59d` 上,**主题卡网格早已下线**:`starter-home.tsx` 只剩 hero 卡 + 两张入口卡 + 错误行,`globals.css` 里 `.starter-theme-*` 一条不剩。任务书写于旧观察,此处以代码为准,工作量相应变小。 + +「输入框被压在约 800px 内容之下」这条仍然成立(hero 卡 padding 40px + `h1` clamp 到 52px,加两张 `min-height: 228px` 的卡)。 + +--- + +## T2.1 · 按原型重排空状态 + +### 结构 + +拆成两个组件,分别落在两个插槽: + +| 组件 | 位置 | 内容 | +|---|---|---| +| `StarterGreeting` | 对话区内,`align-content: end` 底对齐 | 招呼语一行 + 问句 `clamp(26px, 3.2vw, 34px)` | +| (不动)`ChatComposer` | `.composer-wrap`,**与会话态同一个 DOM 位置** | — | +| `StarterEntries` | `.composer-wrap` 内、composer **之后** | 两枚入口 pill + 合并提示行 + 边界句 | + +**为什么不把 composer 搬进空态区**:那会让它在空态与会话态之间挂载到两个不同位置,首条消息落地时 React 会卸载重建——正在输入的草稿、焦点、排队卡都会受影响。现在 composer 一行没动,只是它上下的东西换了。 + +### 与会话态同宽 + +`.welcome` 与 `.composer-wrap-starter .composer` 都改为 `min(var(--session-column-width), 100%)`。此前空态 composer 是 1040px、会话态 760px,**发出第一条消息的瞬间输入框会当场变窄一截**。这是原断言没覆盖到的问题,新断言把它锁住了。 + +### 验收对照 + +| 任务书验收标准 | 结果 | +|---|---| +| 1440×900 / 1280×720 下问候与输入框同屏可见 | ⚠️ **环境缺口**,无 Chrome 无法实测。静态推算:问候块约 90px + composer 约 96px + pill 区约 84px + 内边距,合计约 330px,远小于两个视口的可用高度,但必须真机确认 | +| 空态输入框宽度与会话态一致 | ✅ 两处都是 `--session-column-width`,并新增断言 | +| onboarding 分步 / `productEntrypointsDisabled` / `rectificationEntrySummary` 三分支保留 | ✅ 见下 | +| 首屏 gzip 相对 R1 应下降 | ✅ **41,096 → 41,044 字节(−0.13%)** | + +**三分支这条我第一版做错了**:我把校正入口的文案从三个分支收成了两个,漏掉了 `rectificationCardAction === "restart"`。是 `rectification-agentic-entry` 的断言打红才发现的,已补回三分支(入口标签本来就由服务端 entry summary 驱动,不读会话列表)。 + +### 一处内容取舍 + +`dailyStarlanguage.card.action`(今日星语卡片上的「行动建议」行)在 pill 形态下没有位置,对应的 `dailyStarlanguageAction` 变量已删除。卡片降级为 pill 是任务书 D5 授权的;这条行动建议在点进去之后的正文里仍然完整。若产品认为它必须留在首页,需要另立单——现在的 pill 只能承载一个词。 + +--- + +## T2.2 · 文案 + +对照 `frontend/docs/VOICE.md`: + +| 位置 | 文案 | 依据 | +|---|---|---| +| 入口 pill | 「今日星语」/「每日运势」、「生时校正」 | 原样保留,未改 | +| 合并提示行 | `{今日趋势} · {校正提示}` | 原则 3「解释为什么问,一句就够」;两张卡各自的描述段合成一行 | +| 校正提示(首次) | 「不确定出生时间时,用记得住的经历一步步缩小范围」 | 原值「不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。」→ 去掉公文腔,对照 VOICE 好/坏表第 1 行 | +| 校正提示(可重做) | 「上次已经校正完,可以拿最新资料再来一次」 | 原值「上一次校正已经完成,可以基于最新资料再次校正。」→ 同上 | +| 校正提示(有未完成) | 「未完成的那次可以从左侧继续」 | 原值「新建一段独立校正;未完成的记录仍可从左侧历史会话继续。」→ 缩短,语义不变 | +| 边界句 | 「出生时间不足以支持的判断我会说明,不会补造具体分钟。」 | 原值「不支持的个人判断会明确说明,不会补造出生时间。」→ 准确性边界语义完全保留,改为第一人称 | + +--- + +## 收尾验收 + +| 红线 | 结果 | +|---|---| +| 1 · `tsc` 0 错 / `lint` 0 error | ✅ 118 warning,与基线同 | +| 2 · 测试总数 | ✅ tests **3350**(与 R1 后持平),pass 3304,fail 31,失败清单与基线 `comm` 双向比对**零差异** | +| 3 · `/` 保持 `○ Static` | ✅ `/chart` `○`、`/ephemeris` `○`、`/reports` `ƒ` 均不变 | +| 4 · gzip 预期负增长 | ✅ −52 字节(−0.13%)。删掉约 108 行 starter CSS,同时新增了 pill 与问候的规则,净减不大 | +| 5 · `page.tsx` 增长门禁 | ✅ 净删(删掉一个派生变量与十几行 props),`home-shell-growth-contract` 绿 | +| 6 · 一律 `ChatComposer` | ✅ composer 一行未动 | +| 7 · 无 spinner / 骨架 | ✅ `data-opening` 仍只换 `cursor: progress` | +| 10 · 同提交更新 `DESIGN.md` | ✅ 「Product entrypoint card」整节重写为「Starter home」;顺带删掉「Suggestion card」死文档(它描述的主题卡样式正是本轮删除的 CSS) | +| 11 · 不升依赖、不留新 warning | ✅ | + +### 两处我自己造成的问题,记在这里 + +**一、CSS 规则删除器切坏了一条注释。** 我用脚本按类名批量删 starter 规则时,对逗号分隔的选择器列表做了裁剪。有一条规则前面带注释: + +``` +/* The card is opening a Case: static copy and a progress cursor, no spinner (...). */ +.product-entrypoint-card[data-opening="true"], +.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; } +``` + +注释里那个逗号被当成了选择器分隔符,裁剪时把 `*/` 一起丢掉了。结果一个未闭合注释吞掉了后面 **2762 个字符**,连带 `.session-messages-loading`、`.rectification-entry-error`、`.message-thinking` 等四条无关规则在 `class-name-definition-contract` 眼里「不存在」。已删除该残片,并复核全文注释配平(69 / 69)、扫描所有含花括号的注释(0 条)、确认 `git diff` 里被删的非 starter 选择器只有一条(是我有意改的 `.conversation.is-empty`)。 + +**二、第一版差点误删共享的按压动效规则。** 那条 `:active` 规则的选择器列表里同时有 `.starter-list > button`(死)和 `.new-chat` / `.button-primary` / `.composer button` 等十几个活选择器。第一版脚本按「选择器命中死类名就删整条」,会把整个按压动效干掉。已改成「逐个选择器判定,全死才删整条,否则只摘掉死的那个」,并复核该规则现在仍有 13 个活选择器。 + +### 一条性能问题,顺手记下 + +`consultation-entrypoint` 里有一条断言写成: + +```js +const start = source.indexOf('
` 细则 / 手机断点卡片高度与对齐 / hero 与卡标题字重 400 / `indexOf` 切片查技术参数 | 对应意图全部迁到 `.starter-entry` 与 `.starter-greeting-block` | 见上;其中「hover 只动描边、按下才改底色」这条意图我专门补了 CSS 规则去承接,没有丢 | +| `onboarding-presentation` | `className="starter-greeting"` | `className="starter-salutation"` | 旧类名既指招呼语行、又被读成整块问候区;拆成 `-block` / `-salutation` 消歧。「两行」意图不变 | +| `rectification-agentic-entry` | — | (未改断言,是它打红让我发现少了 restart 分支) | — | +| `rectification-surface-contract` | `.product-entrypoint-card[data-opening]` 的 `cursor: progress` | `.starter-entry[data-opening]` | 选择器跟随形态;「打开 Case 只换指针不出 spinner」不变 | +| `session-conversation-layout` | `.welcome` 与 `.starter-list` 都是 1040px | `.welcome` 为 `--session-column-width`,**新增**锁住 composer 两态同宽,并 `doesNotMatch(/\.starter-list/)` | 比原断言更强:顺带锁住「输入框不跳宽度」 | +| `class-name-definition-contract` | `knownUnstyled` 含 `starter-workbench` | 移除该项 | 类名已不存在,白名单里留着会掩盖真问题 | + +其中 3 条(两处 `doesNotMatch`、一条新增的渲染顺序断言)**收紧**了约束,无一条放宽。 + +--- + +## 环境缺口(不得记为通过) + +1. **1440×900 与 1280×720 下问候与输入框同屏可见** —— 本轮的核心验收项,静态推算通过但必须真机确认。 +2. **入口 pill 在窄屏的换行与触摸目标**(36px 高,低于 44px 推荐值,需确认实际可点性)。 +3. **空态 → 首条消息的过渡**:确认输入框不跳宽度、草稿不丢、焦点不丢。 + +--- + +## 下一轮 + +R3(侧栏拍平 + 星盘入口收敛 + 去头像)基线取本轮合入后的 `origin/staging`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e228528f..47710bce 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -156,7 +156,7 @@ | — | `PROGRESS-react-compiler-20260817.md` | React Compiler 接管首页 | 已回滚 | 结论见 `BLOCKED.md` | | `TASK-engine-runtime-hygiene-20260901.md` | `PROGRESS-engine-runtime-hygiene-20260901.md` | Python 引擎运行时治理 | 已验收 | `124d3990`(staging 实机三条待部署窗口) | | `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` | -| `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-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1-20260916.md`、`PROGRESS-cend-ui-r2-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 8009e06a..6cf8f1d5 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -370,13 +370,6 @@ Every assistant reply moves through the same states on both chat surfaces, and e Text release is paced, not animated: the frame buffer commits at most once per animation frame and reveals `max(2, ⌈backlog ÷ 12⌉)` characters per frame, so a burst catches up in about twelve frames and a slow model never reads as stalled. The reader's own toggle on the timeline always wins over the state default. Under `prefers-reduced-motion: reduce` the collapse and the label fade are instant; the pacing stays, because it is content arrival rather than decoration. -### Suggestion card - -- **Structure:** topic label, question, directional icon. Categories are not numbered because they have no required order. At tablet widths, the cards stack into one column so Chinese questions keep natural phrase boundaries beside the persistent sidebar. -- **Surface:** warm light cards; the lead card uses the pale brown emphasis surface and border instead of a dark block. -- **States:** default, hover, active, focus, disabled, loading, fallback notice. -- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message. - ### Personal report centre - **Structure:** full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards. @@ -399,19 +392,49 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy. - **Accessibility:** back, print, and Markdown export are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. -### Product entrypoint card +### Starter home -- **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”. -- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal questions as “事业 · …”, and rectification as “8月21日 · 生时校正”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. The homepage no longer shows a “从一个主题开始” grid; leftover `guided_topic` values in stored sessions still pin domains when replayed. -- **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback. -- **Responsive:** cards stack below 900px without introducing a large nested button. Stacked cards hug their copy instead of stretching to a min-height; the footer stacks supporting copy above a leading-aligned one-line action. -- **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target. +The empty state is the composer. It used to be a bordered hero card at +`clamp(34px, 5vw, 52px)` plus two 132px product cards — roughly 800px of content +sitting on top of the input, so the primary action was the last thing on the +page. Three parts now, in reading order: -### Start greeting +- **Greeting** (`.starter-greeting-block`): a tertiary-ink salutation line and + one question at `clamp(26px, 3.2vw, 34px)`, weight 500, centred. It lives in + the conversation area with `align-content: end`, so it sits directly above the + composer rather than floating in the middle of the row. +- **Composer**: unchanged, and *in the same DOM position it occupies during a + live session*. Nothing remounts when the first message lands, so an in-flight + draft, focus, or queued card survives the transition. Its width is + `--session-column-width` in both states — the empty-state composer used to be + 1040px against the session's 760px, so the input visibly narrowed the moment + a conversation started. +- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside + `.composer-wrap` *below* the composer. 36px pills, hairline, accent icon, one + word each. Below them one line merges the daily trend with the rectification + hint, and an accuracy-boundary line appears when no natal minute is available. -- **Content:** invite the user to ask what matters now; do not repeat that birth data is ready or explain setup state. -- **Timing:** use the browser's local hour: morning 05:00–10:59, noon 11:00–13:59, afternoon 14:00–17:59, evening 18:00–22:59, and late night 23:00–04:59. -- **Variation:** each time band has three concise prompts; select one once per visit so re-renders do not change the sentence. +- **States:** default, hover, pressed, focus-visible, disabled, and `data-opening` + while a Case is being opened. Hover only moves the hairline; the surface tint + belongs to the pressed state, because a hover that shades the surface reads as + "selected" and sticks on touch. Opening a Case changes the cursor to `progress` + and nothing else — no spinner (unified-loading ruling). +- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal + questions as “事业 · …”, and rectification as “8月21日 · 生时校正”. Clicking the + daily pill starts a consultation session immediately; it does not place the + question in the composer. Private model instructions are selected by a closed + entrypoint identifier and expanded only on the server. There is no + “从一个主题开始” grid; leftover `guided_topic` values in stored sessions still + pin domains when replayed. +- **Rectification hint** keeps three server-driven branches — an unfinished case, + a finished one that can be redone (`rectificationCardAction === "restart"`), + and a first run. The branch comes from the entry summary, never the session + list. +- **Responsive:** the pill row wraps and stays centred; there is no + phone-specific layout, because there is no fixed-height card to collapse. +- **Accessibility:** each pill is one native button with a descriptive accessible + name (the full action label, not the one-word face). The daily hint keeps + `role="status"` while the trend is still arriving. ### Account popover @@ -483,7 +506,7 @@ Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motio ## 7. Depth & Surface -Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session, the starter theme card's focus state and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals. +Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals. ## 8. Agent wording and accuracy boundary diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e5c01376..21a0c102 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -356,7 +356,7 @@ button:disabled { cursor: default; opacity: .45; } .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; } + .message-assistant { align-items: flex-start; justify-content: flex-start; gap: var(--space-3); } .message-user { justify-content: flex-end; } .message-user .message-content { display: flex; flex-direction: column; align-items: flex-end; } @@ -558,7 +558,7 @@ button:disabled { cursor: default; opacity: .45; } .status-已过期, .status-已兑换 { color: var(--color-ink-secondary); } .empty-cell { color: var(--color-ink-secondary); text-align: center !important; } -.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .starter-list > button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.96); } +.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active{ transform: scale(.96); } @keyframes app-loading-orbit { to { transform: rotate(360deg); } } @keyframes inline-spin { to { transform: rotate(360deg); } } @@ -881,7 +881,11 @@ button:disabled { cursor: default; opacity: .45; } .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; } .conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } -.conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); } +/* Empty state. The greeting is bottom-aligned inside the conversation row and + the entry pills live under the composer, so the greeting / composer / pills + stack reads as one centred group while the composer keeps the single DOM + position it has in a live session. */ +.conversation.is-empty { display: grid; align-content: end; justify-items: center; padding: var(--space-8) var(--space-8) 0; } .welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); } .welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); } .welcome > .onboarding-message:first-child .message-bubble p { @@ -1046,37 +1050,7 @@ button:disabled { cursor: default; opacity: .45; } .conversational-original-question { background: var(--color-success-muted); } .phrase-nowrap { white-space: nowrap; } -.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; } -.starter-arrow { width: 17px; height: 17px; color: var(--color-ink-secondary); } -.product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: var(--space-3); } -.product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); } -.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); } -.product-entrypoint-card { - position: relative; - overflow: hidden; - transition: background-color 180ms ease-out; -} -.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } -.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; } -/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */ -.product-entrypoint-card[data-opening="true"], -.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); } -.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; } -@media (hover: hover) { - .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); transform: translateY(-1px); } -} -.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: 1; } -.product-entrypoint-hitarea:disabled { cursor: not-allowed; } -.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; } -.daily-starlanguage-card dl > div, .birth-rectification-card dl > div { display: grid; gap: 4px; } -.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); } -.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; } -.product-entrypoint-footer { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3); } -.product-entrypoint-footer small { min-width: 0; flex: 1; } -.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; } -.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; } .rectification-entry-error { grid-column: 1 / -1; margin: 0; } .message-list { --assistant-content-inset: calc(32px + var(--space-3)); @@ -1927,7 +1901,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } @media (min-width: 768px) and (max-width: 1023px) { - .product-entrypoints { grid-template-columns: 1fr; } + .auth-shell { grid-template-columns: 1.2fr .8fr; } .auth-story, .auth-panel { padding: var(--space-8); } .auth-story h2 { font-size: var(--type-display-sm); } @@ -1955,8 +1929,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .welcome { padding: var(--space-6) 0 var(--space-10); } .welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-display-sm); } - .starter-list { grid-template-columns: 1fr; grid-template-rows: auto; } - .product-entrypoints { grid-template-columns: 1fr; } + + .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)); } @@ -2022,268 +1996,136 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; } } -/* Starter workbench */ +/* Starter home: greeting, composer, entry pills */ .welcome { - width: min(1040px, 100%); + width: min(var(--session-column-width), 100%); margin-inline: auto; - padding: var(--space-8) 0 var(--space-12); + padding: var(--space-8) 0 var(--space-5); } -.starter-list { - width: min(1040px, 100%); - margin-inline: auto; - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-6); - border: 0; -} - -.starter-hero, -.product-entrypoints { +/* The greeting: one line of salutation, one question. It used to be an h1 at + clamp(34px, 5vw, 52px) inside a bordered hero card, with two 132px product + cards under it — together about 800px sitting on top of the composer. */ +.starter-greeting-block { width: 100%; min-width: 0; + text-align: center; } -.starter-hero { - min-height: 0; - display: block; - padding: var(--space-10); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-canvas-soft); -} - -.starter-hero-copy { max-width: 720px; } - -.starter-greeting { - margin: 0 0 var(--space-3); - color: var(--color-action); +.starter-salutation { + margin: 0 0 var(--space-2); + color: var(--color-ink-tertiary); font-size: var(--type-body-sm); - font-weight: 600; } -.starter-hero h1 { - max-width: 760px; +.starter-greeting-block h1 { margin: 0; color: var(--color-ink); font-family: var(--font-display); - font-size: clamp(34px, 5vw, 52px); + font-size: clamp(26px, 3.2vw, 34px); font-weight: 500; - letter-spacing: -.04em; - line-height: 1.08; + letter-spacing: -.02em; + line-height: 1.3; text-wrap: balance; } -.product-entrypoints { +/* Entry pills, under the composer. The two product entrances keep their place + in the product but not their weight: they were 132px cards competing with the + input for the first look. */ +.starter-entries { + width: min(var(--session-column-width), 100%); display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); + gap: var(--space-2); + margin: var(--space-4) auto 0; + justify-items: center; +} + +.starter-entry-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2); +} + +.starter-entry { + min-height: 36px; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-4); + border: 1px solid var(--color-border-strong); + border-radius: 999px; background: var(--color-canvas); -} - -.product-entrypoints small { - color: var(--color-ink-secondary); - line-height: 1.5; - font-size: var(--type-caption); -} - -.daily-starlanguage-card, -.birth-rectification-card { - min-height: 228px; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: var(--space-6); - padding: var(--space-8); - border: 0; - border-radius: 0; - background: transparent; -} - -.birth-rectification-card { - border-left: 1px solid var(--color-border); -} - -.product-entrypoint-card { - position: relative; - overflow: hidden; - transition: background-color 180ms ease-out; -} - -.product-entrypoint-card::after { - position: absolute; - inset: 0; - opacity: 0; - pointer-events: none; - background: linear-gradient(112deg, transparent 22%, var(--sheen) 46%, transparent 68%); - content: ""; - transform: translateX(-48%); - transition: opacity 220ms ease-out, transform 520ms var(--ease-out); -} - -.product-entrypoint-hitarea { - position: absolute; - z-index: 3; - inset: 0; - width: 100%; - min-height: 0; - padding: 0; - border: 0; - border-radius: inherit; - background: transparent; + color: var(--color-ink); cursor: pointer; + font-size: var(--type-body-sm); + transition: border-color 140ms ease-out; } -.product-entrypoint-card > :not(.product-entrypoint-hitarea) { - position: relative; - z-index: 1; - pointer-events: none; +.starter-entry > svg { + width: 15px; + height: 15px; + flex: 0 0 15px; + color: var(--color-action); } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { - border-color: transparent; - background: color-mix(in srgb, var(--color-action-soft) 58%, var(--color-canvas)); +.starter-entry[data-opening="true"] { cursor: progress; } +.starter-entry:disabled { cursor: not-allowed; } + +/* Pressed feedback is transient; hover only moves the hairline. A hover that + shades the surface reads as "selected" and sticks on touch devices. */ +.starter-entry:not(:disabled):active { + border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border-strong)); + background: var(--color-action-soft); } @media (hover: hover) { - .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { - border-color: transparent; - transform: none; - } - - .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover)::after { - opacity: .65; - transform: translateX(48%); - } - - .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) .product-entrypoint-action { - transform: translateX(3px); - } + .starter-entry:not(:disabled):hover { border-color: var(--color-action); } } -.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { +.starter-entry:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); - outline-offset: -3px; + outline-offset: 2px; } -.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { - opacity: 1; -} - -.product-entrypoint-hitarea:disabled { - cursor: not-allowed; -} - -.product-entrypoint-copy { - max-width: 520px; -} - - -.product-entrypoint-copy h2 { +.starter-entry-hint, +.starter-entry-boundary { + max-width: 560px; margin: 0; - color: var(--color-ink); - font-family: var(--font-display); - font-size: clamp(24px, 2.4vw, 31px); - font-weight: 500; - letter-spacing: -.03em; - line-height: 1.12; -} - -.product-entrypoint-copy p { - max-width: 460px; - margin: var(--space-3) 0 0; - color: var(--color-ink-secondary); - font-size: var(--type-body-sm); - line-height: 1.65; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.55; + text-align: center; text-wrap: pretty; } -.product-entrypoint-copy p[aria-busy="true"] { - color: var(--color-ink-tertiary); +.starter-entry-boundary { color: var(--color-ink-tertiary); } + + +@media (hover: hover) { + + } -.product-entrypoint-footer { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--space-5); - padding-top: var(--space-5); - border-top: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent); -} - -.product-entrypoint-footer small { - min-width: 0; - flex: 1; -} - -.product-entrypoint-footer small:empty { - display: none; -} - -.product-entrypoint-action { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: flex-end; - margin-left: auto; - gap: var(--space-1); - color: var(--color-action); - font-size: var(--type-caption); - font-weight: 600; - white-space: nowrap; - transition: transform 180ms var(--ease-out); -} - -.product-entrypoint-action .starter-arrow { - width: 15px; - height: 15px; - color: currentColor; -} - -.starter-arrow { - width: 17px; - height: 17px; - color: var(--color-ink-secondary); - transition: color 180ms ease-out, transform 180ms var(--ease-out); -} +/* One column width across both states: the empty-state composer used to be + 1040px while the session composer was 760px, so the input visibly jumped + width the moment the first message landed. */ .composer-wrap-starter .composer { - width: min(1040px, 100%); + width: min(var(--session-column-width), 100%); margin-inline: auto; } +.composer-wrap-starter { + border-top-color: transparent; + padding-bottom: var(--space-16); + background: transparent; + backdrop-filter: none; +} + @media (min-width: 768px) and (max-width: 1023px) { - .starter-hero { - padding: var(--space-8); - } - .product-entrypoints { - grid-template-columns: 1fr; - } - .daily-starlanguage-card, - .birth-rectification-card { - min-height: 0; - grid-template-rows: auto auto; - } - - .birth-rectification-card { - border-top: 1px solid var(--color-border); - border-left: 0; - } - - .product-entrypoint-footer { - flex-direction: column; - align-items: flex-start; - gap: var(--space-3); - } - - .product-entrypoint-action { - margin-left: 0; - justify-content: flex-start; - } } @media (max-width: 767px) { @@ -2291,62 +2133,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class padding: var(--space-4) 0 var(--space-8); } - .starter-list { - grid-template-columns: 1fr; - grid-template-rows: auto; - gap: var(--space-5); - } - .starter-hero { - min-height: 0; - grid-template-columns: 1fr; - padding: var(--space-8) var(--space-6); - } - - .starter-hero h1 { - font-size: clamp(38px, 11vw, 54px); - } - - .product-entrypoints { - grid-template-columns: 1fr; - } - - .daily-starlanguage-card, - .birth-rectification-card { - min-height: 0; - grid-template-rows: auto auto; - gap: var(--space-4); - padding: var(--space-5); - } - - .birth-rectification-card { - border-top: 1px solid var(--color-border); - border-left: 0; - } - - .product-entrypoint-copy h2 { - font-size: var(--type-title-md); - } - - .product-entrypoint-footer { - flex-direction: column; - align-items: flex-start; - gap: var(--space-3); - padding-top: var(--space-4); - } - - .product-entrypoint-action { - margin-left: 0; - justify-content: flex-start; - font-size: var(--type-body-sm); - font-weight: 500; - } } @media (max-width: 480px) { - .starter-hero { - padding: var(--space-6) var(--space-5); - } + } /* Session conversation */ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f5823076..e666bb77 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -42,7 +42,7 @@ import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { BirthLocationFields } from "@/components/birth-location-fields"; import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; import { ChartLibraryPanel } from "@/components/chart-library-panel"; -import { StarterHome } from "@/components/starter-home"; +import { StarterEntries, StarterGreeting } from "@/components/starter-home"; import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, isGeneralDailyFortuneQuestion, @@ -645,7 +645,6 @@ export default function Home() { const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.trend : "今天的星语还没写出来。"; - const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : ""; const bootstrapPrepareReady = bootstrapPrepareSettled({ dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable, dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending", @@ -1714,25 +1713,7 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && ( - + )}
) : sessionMessagesLoading ? ( @@ -1819,6 +1800,25 @@ export default function Home() { )} onStop={() => void stopResponse()} /> + {starterHomeVisible && ( + + )} } diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index 517ba009..9f4bc970 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -1,16 +1,41 @@ "use client"; -import { ArrowUpRight } from "lucide-react"; +import { Clock3, Sparkles } from "lucide-react"; import type { DailyStarlanguageState } from "@/lib/home-types"; import type { RectificationEntrySummary } from "@/lib/rectification-entry"; -export type StarterHomeProps = { +/** + * The empty state is split across two slots on purpose. + * + * `StarterGreeting` sits in the conversation area, bottom-aligned, so it lands + * directly above the composer. `StarterEntries` sits inside `.composer-wrap`, + * below the composer. The composer itself never moves: it keeps the one DOM + * position it has in a live session, so nothing remounts when the first message + * arrives and an in-flight draft, focus or queued card survives the transition. + * + * Before this, the empty state was a bordered hero card plus two 132px product + * cards, and the composer sat under roughly 800px of content — the primary + * action was the last thing on the page. + */ + +export type StarterGreetingProps = { readonly starterGreeting: { readonly salutation: string; readonly question: string }; +}; + +export function StarterGreeting({ starterGreeting }: StarterGreetingProps) { + return ( +
+

{starterGreeting.salutation}

+

{starterGreeting.question}

+
+ ); +} + +export type StarterEntriesProps = { readonly dailyStarlanguage: DailyStarlanguageState; readonly natalMinuteAvailable: boolean; readonly dailyStarlanguageQuestion: string; readonly dailyStarlanguageTrend: string; - readonly dailyStarlanguageAction: string; readonly productEntrypointsDisabled: boolean; readonly startDailyStarlanguageConsultation: () => void; readonly rectificationCardLabel: string; @@ -24,13 +49,11 @@ export type StarterHomeProps = { readonly rectificationErrorMessage: string; }; -export function StarterHome({ - starterGreeting, +export function StarterEntries({ natalMinuteAvailable, dailyStarlanguage, dailyStarlanguageQuestion, dailyStarlanguageTrend, - dailyStarlanguageAction, productEntrypointsDisabled, startDailyStarlanguageConsultation, rectificationCardLabel, @@ -42,69 +65,60 @@ export function StarterHome({ rectificationError, rectificationSurfaceOpen, rectificationErrorMessage, -}: StarterHomeProps) { +}: StarterEntriesProps) { + const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势"; + /* The trend line is the only part that arrives late. It stays a live region so + a screen reader hears it settle, but it is a tooltip-rank hint now, not a + card body — the pill is actionable from the first paint either way. */ + const dailyHint = natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏"; + /* Three branches, same as the card it replaces: an unfinished case, a finished + one that can be redone, and a first run. The label itself is server-driven + from the entry summary (`rectificationCardAction`), not from the session list. */ + const rectificationHint = rectificationEntrySummary?.hasResumableCase + ? "未完成的那次可以从左侧继续" + : rectificationCardAction === "restart" + ? "上次已经校正完,可以拿最新资料再来一次" + : "不确定出生时间时,用记得住的经历一步步缩小范围"; + return ( -
-
-
-

{starterGreeting.salutation}

-

{starterGreeting.question}

-
-
- -
-
-
-
-
-
- {rectificationError && !rectificationSurfaceOpen && ( -

- {rectificationErrorMessage} -

- )} -
- +
+
+ + +
+

+ {dailyHint} · {rectificationHint} +

+ {!natalMinuteAvailable && ( +

出生时间不足以支持的判断我会说明,不会补造具体分钟。

+ )} + {rectificationError && !rectificationSurfaceOpen && ( +

+ {rectificationErrorMessage} +

+ )} +
); } diff --git a/frontend/tests/chat-reading-load-home.test.ts b/frontend/tests/chat-reading-load-home.test.ts index 10880648..40c14de8 100644 --- a/frontend/tests/chat-reading-load-home.test.ts +++ b/frontend/tests/chat-reading-load-home.test.ts @@ -19,8 +19,12 @@ test("the homepage no longer renders theme cards", () => { assert.doesNotMatch(starterHome, /starter-themes|starter-theme-card|starter-theme-accordion|starter-themes-heading/); assert.doesNotMatch(starterHome, /从一个主题开始/); assert.doesNotMatch(starterHome, /starterThemes|starterSuggestions|onboardingError/); - assert.match(starterHome, /className="starter-hero"/); - assert.match(starterHome, /className="product-entrypoints"/); + // 原值:`starter-hero` 卡 + `product-entrypoints` 两张 132px 大卡 + // 新值:`starter-greeting-block` 一行问候 + `starter-entry` 两枚 pill + // 原因:输入框此前被压在约 800px 内容之下,主动作不是视觉焦点。 + assert.match(starterHome, /className="starter-greeting-block"/); + assert.match(starterHome, /className="starter-entry"/); + assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint/); }); test("the homepage no longer requests /api/onboarding", () => { @@ -61,12 +65,17 @@ test("guided_topic remains readable for history but has no homepage emit point", assert.doesNotMatch(page, /"guided_topic"/); }); -test("the empty home is greeting, two product cards, and the composer", () => { - const start = starterHome.indexOf('
{ + assert.match(starterHome, /starter-greeting-block/); + assert.match(starterHome, /starter-entry-row/); + assert.doesNotMatch(starterHome, /starter-theme|daily-starlanguage-card|birth-rectification-card/); assert.match(page, /composer-wrap-starter/); + // 入口 pill 必须渲染在 composer 之后,否则又回到「输入框被压在下面」。 + assert.ok( + page.indexOf(" page.indexOf(" { +// 原值:两个 `.product-entrypoint-hitarea` 绝对定位盖满卡片 +// 新值:两个 `.starter-entry` 原生 button +// 原因:卡片降级成 pill 后,按钮本身就是可点区域,不再需要盖一层 hitarea。 +// 意图不变:两个入口都是原生 button,且内部不嵌套第二个可点元素。 +test("homepage entry points are exactly two native buttons with nothing nested inside", () => { const source = homeSurface; - const wholeCardActions = source.match(/className="product-entrypoint-hitarea"/g) ?? []; + const entries = source.match(/className="starter-entry"/g) ?? []; - assert.equal(wholeCardActions.length, 2); - assert.doesNotMatch(source, /className="daily-starlanguage-heading">[\s\S]{0,180}