feat(home): 首页开场语改成一行问候,占位符改「想聊什么都可以」
Independent Staging Quality Gate / validate (push) Failing after 6m23s
Independent Staging Quality Gate / publish (push) Skipped

开场语从「称呼行 + 追问句」收成 claude.ai 式的单行问候:时段池、
星期池、回访池按顺序拼成一个数组,按 variantSelection 确定性取一条;
名字为空时跳过带称呼的条目。副标题行与 .starter-salutation 下线,
h1 降到 clamp(24px, 2.8vw, 30px) / line-height 1.25。追问改由输入框
占位符承担。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-17 15:54:20 +00:00
co-authored by Claude Fable 5.1
parent dd281dd9c9
commit ff0427cf08
13 changed files with 318 additions and 97 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-09-17 — 首页开场语改成一行问候
首页空对话的开场语从「一行称呼 + 一句追问」收成一行问候,例如「早上好,周宁」「还没睡呀,周宁」「欢迎回来」。每次回到首页重抽一次:按当前时段(早晨 / 中午 / 下午 / 傍晚 / 深夜)挑,周一、周五、周六、周日各多一条应景的,账号里已经有对话时还会出现「又见面了」这类回访问候;没填称呼时只出不带称呼的那几条。字号从最大 34px 降到最大 30px,副标题那一行去掉。追问改由输入框占位符承担,文案从「例如:未来半年是否适合换工作?」改成「想聊什么都可以」。Skill 版本不变。
## 2026-09-17 — 咨询不再整轮失败:每一轮先取回本轮星盘再回答 ## 2026-09-17 — 咨询不再整轮失败:每一轮先取回本轮星盘再回答
部署 `dc2f2a16` 之后,本命和申报时段咨询会整轮失败,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点。」现在会重新走排盘再回答。短追问(「?」「你在说什么鬼」)仍然每一轮先取回本轮星盘。Skill 版本不变。 部署 `dc2f2a16` 之后,本命和申报时段咨询会整轮失败,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点。」现在会重新走排盘再回答。短追问(「?」「你在说什么鬼」)仍然每一轮先取回本轮星盘。Skill 版本不变。
@@ -0,0 +1,78 @@
# PROGRESS · 首页开场语改成一行问候(2026-09-17)
- 基线 commit:`b51e7aff`(`origin/staging`,2026-09-17 取)
- 分支:`codex/starter-greeting-20260917`,worktree `.worktrees/starter-greeting-20260917`
- 模式:直接执行(产品拍板,非 Bug,不占 BUG 号,未写 `docs/BUG_HISTORY.md`)
- 未推送。
## 1. 做了什么
| # | 改动 | 文件 |
| --- | --- | --- |
| 1 | 开场语从「称呼行 + 追问句」五时段十五条,换成三个池子的单行问候 | `frontend/src/lib/starter-greeting.ts` |
| 2 | `createOnboardingFallbackGreeting` 改成 `欢迎,{名字}` | 同上 |
| 3 | `createStartGreetingParts` 返回 `{ text }`,两个导出都多一个 `hasSessions` 形参 | 同上 |
| 4 | `StarterGreeting` 删掉 `<p className="starter-salutation">`,只留 `<h1 id="starter-heading">{starterGreeting.text}</h1>` | `frontend/src/components/starter-home.tsx` |
| 5 | `hasSessions` 由既有 `sessions` 列表派生(`sessions.some((session) => session.id !== activeSession?.id)`),未新增 `useState` / `useRef` | `frontend/src/app/(app)/page.tsx` |
| 6 | 删 `.starter-salutation` 规则;`.starter-greeting-block h1` 字号 `clamp(26px, 3.2vw, 34px)` → `clamp(24px, 2.8vw, 30px)`,`line-height` 1.3 → 1.25;`--font-display` / 500 / `-.02em` / `text-wrap: balance` 保持;`.starter-greeting-block` 未动 | `frontend/src/app/globals.css` |
| 7 | 普通输入框占位符末行 `例如:未来半年是否适合换工作?` → `想聊什么都可以`(其它分支未动) | `frontend/src/lib/rectification-session-composer-guard.ts` |
| 8 | DESIGN / VOICE / CHANGELOG / 任务索引 | `frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CHANGELOG.md`、`docs/tasks/README.md` |
### 选择规则(实现口径)
给定 `(name, now, variantSelection ∈ [0,1), hasSessions)` 确定性地得到一行:
1. 时段池按 `now.getHours()`(早晨 5–11 / 中午 11–14 / 下午 14–18 / 傍晚 18–23 / 深夜其余),沿用改动前的划分;
2. 星期池按 `now.getDay()`,只有周一 / 周五 / 周六 / 周日各一条,其余星期不贡献条目;
3. 回访池只在 `hasSessions` 为真时拼进去;
4. 三池按「时段 → 星期 → 回访」顺序拼成一个数组;`name.trim()` 为空时先滤掉所有带 `{名字}` 的条目(时段池每段都留有不带称呼的条目,所以结果永不为空);
5. 下标 `clamp(floor(variantSelection * pool.length), 0, pool.length - 1)`,再把 `{名字}` 换成称呼。
句尾无句号;`今天过得怎么样?` 与 `最近还好吗,{名字}?` 保留问号。
## 2. 测试数字
| 项 | 基线 `b51e7aff` | 本轮 | 结论 |
| --- | --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 0 错 | 0 错 | 通过 |
| `npm run lint` | 0 error / 118 warning | 0 error / 118 warning | 通过(warning 数量未变,未顺手修) |
| `npm test` 总数 | 3458 | 3465 | +7 |
| `npm test` pass | 3407 | 3414 | +7 |
| `npm test` fail | 36 | 36 | 失败清单逐条相同(见下) |
| `npm test` skipped | 15 | 15 | — |
| `next build` 的 `/` | `○ Static` | `○ Static` | 通过 |
| 首屏 gzip(`.next/static` 全部 `*.js` 逐个 gzip 后字节合计,68 个文件,同机同命令各测一次) | 1,450,864 B | 1,450,774 B | −90 B,−0.006%,在 ±2% 内 |
失败清单逐条比对:基线 36 条与本轮 36 条排序后 `diff` 为空(`admin code functions…`、`rectification agent maps setup failures…`、`generating does not disable the textarea…`、若干 `database` / `ephemeris` / `live staging sync` 套件等)。全部是无 Docker / 无数据库 / 无外部引擎的既有环境缺口,与本轮无关。
`next build` 环境备忘:worktree 里把 `node_modules` 软链到 `.worktrees/accept-20260916/frontend/node_modules` 时,Turbopack 直接 panic(`Symlink [project]/frontend/node_modules is invalid, it points out of the filesystem root`)。按开工说明改成 `rm node_modules && npm ci` 后构建正常。基线数字的取法:`git diff > /tmp/work.patch` → `git checkout -- frontend/` → 构建取数 → `git apply /tmp/work.patch`(未用 `git stash`,AGENTS §3.5)。
## 3. 被改的既有断言(原值 / 新值 / 原因)
| 文件 · 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `tests/onboarding-presentation.test.ts` · `terminal and fallback greetings follow the current profile name` | `createStartGreeting("周宁", SUNDAY(8), 0)`;`fallbackGreeting === "周宁,从你此刻最关心的问题开始吧。"` | `createStartGreeting("周宁", SUNDAY(8), 0.25)`(= `早上好,周宁`);`fallbackGreeting === "欢迎,周宁"` | 时段池第一条改成不带称呼的「早上好」,要取带称呼的条目得换下标;兜底问候按产品口径改成单行。两条「必须带当前称呼、不得带旧称呼」的断言原样保留,另加了逐字相等断言,强度不降 |
| 同上 · `the starter hero splits one time-aware greeting…` → `the starter greeting is one line drawn from daypart, weekday and returning pools` | 断言 `parts.salutation === "中午好,周宁。"`、`parts.question === "现在最想理清哪件事?"`、两半拼起来等于整行、每时段至少 12 条不同问句 | 断言 `parts.text` 按下标取到「中午好」/「中午好,周宁」,`createStartGreeting` 与 `parts.text` 相等,`variantSelection` 为 1 / 0.999999 不越界 | 「招呼语 + 问句」两半的形状被产品取消,问句池不再存在,原断言的被测对象没了。边界不越界这一条原样保留 |
| 同上 · `the starter hero stays at a salutation and a question` → `the starter hero is one heading and nothing else` | 断言 `<p className="starter-salutation">{starterGreeting.salutation}</p>` 与 `<h1 …>{starterGreeting.question}</h1>` 同时存在 | 断言只剩 `<h1 id="starter-heading">{starterGreeting.text}</h1>`,且 `starter-salutation`、`starterGreeting.salutation|question` 在源码与 `globals.css` 里都不得出现 | 副标题行连同它的样式一起下线;断言从「必须有两行」改成「只有一行且不得有第二行」,是收紧不是放松 |
| `tests/consultation-entrypoint.test.ts` · `the starter heading is drawn per visit…` | `<h1 id="starter-heading">{starterGreeting.question}</h1>` | `<h1 id="starter-heading">{starterGreeting.text}</h1>` | 同上;「每次进首页重抽一次」(`useState(() => Math.random())` + 离开首页重抽)两条断言原样保留 |
| `tests/consultation-entrypoint.test.ts` · `the starter greeting keeps the display stack and display weight` | `font-size: clamp(26px,` | `font-size: clamp(24px, 2.8vw, 30px);`,并新增 `line-height: 1.25;` | 单行之后不需要 34px 上限那一档。原断言只匹配前缀,新断言匹配完整 clamp 并多钉一条 `line-height`,强度提高;`--font-display` / `font-weight: 500` / 不得是 `--font-body` 三条原样保留 |
| `tests/chat-composer-queue.test.ts` · `a queued card renders above the live textarea` | fixture `placeholder: "例如:未来半年是否适合换工作?"` | `placeholder: "想聊什么都可以"` | 只是渲染 `ChatComposer` 时传的样例 prop,不是被断言的对象;跟着产品文案更新以免留下过期字面量。断言本身未动 |
## 4. 新增测试(`tests/onboarding-presentation.test.ts`,3 条 → 10 条)
| 测试 | 覆盖 |
| --- | --- |
| `late night has its own three lines and none of them asks a follow-up` | 深夜三条逐字;23 点与 4 点同属深夜 |
| `a blank name leaves only the lines that carry no name` | 名字为空(含全空白)只出不带称呼的条目;周一条目与回访池整条不可达;结果非空、不残留 `{名字}`、不以逗号收尾 |
| `the weekday pool only opens on its own day` | 周五能选到 `周五快乐`,周三选不到且池子恰好 3 条;周一 / 周日各自的条目可达 |
| `the returning pool needs at least one session behind the account` | `hasSessions=false` 时四条回访句一条都选不到(池子 3 条);`hasSessions=true` 时四条全部可达(池子 7 条) |
| `the same inputs always produce the same line` | 同一 `(name, now, variantSelection, hasSessions)` 连调两次结果相同 |
| `no greeting line uses a metaphor or trails off into a sentence` | 7 天 × 24 小时 × 2 种称呼 × 2 种 `hasSessions` × 100 个 selection:任何条目不含 `星` `月` `陪`、不以 `。` 结尾、长度 ≤ 12 |
| `the greeting knows whether the account already has sessions` | `hasSessions` 必须由既有 `sessions` 列表派生,`page.tsx` 不得另开状态 |
`frontend/tests/home-shell-growth-contract.test.ts` 未改动且仍绿:`page.tsx` 只多了一行 `const starterHasPriorSessions = …`,`useState` / `useRef` 计数不变。
## 5. 环境缺口
- 无登录态、无浏览器走查:真实首页上「一行问候居中、不再一大一小、占位符文案」的观感未在浏览器里确认,仅有 `next build` + 源码/样式合同。
- 本轮未跑 `npm run test:db`(未动数据库结构)、未跑 Python 测试(无 Python 改动)。
+1
View File
@@ -137,6 +137,7 @@
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — | | `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` | | `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 | | `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
| — | `PROGRESS-starter-greeting-20260917.md` | 首页开场语改成 claude.ai 式单行问候:`starter-greeting.ts` 的「称呼 + 追问句」五时段十五条收成三个池子(时段 / 星期 / 回访,按 `variantSelection` 确定性取一条),副标题行与 `.starter-salutation` 下线,h1 降到 `clamp(24px, 2.8vw, 30px)`;追问移到输入框占位符「想聊什么都可以」。产品直接拍板,非 Bug,不占 BUG 号 | 待验收 | `codex/starter-greeting-20260917` |
### 个人报告 ### 个人报告
+10 -4
View File
@@ -471,10 +471,16 @@ The empty state is the composer. It used to be a bordered hero card at
sitting on top of the input, so the primary action was the last thing on the sitting on top of the input, so the primary action was the last thing on the
page. Three parts now, in reading order: page. Three parts now, in reading order:
- **Greeting** (`.starter-greeting-block`): a tertiary-ink salutation line and - **Greeting** (`.starter-greeting-block`): one line, no subtitle — a single h1
one question at `clamp(26px, 3.2vw, 34px)`, weight 500, centred. It lives in at `clamp(24px, 2.8vw, 30px)`, weight 500, `line-height: 1.25`, centred. The
the conversation area with `align-content: end`, so it sits directly above the salutation line above the question is gone; so is the question, which is the
composer rather than floating in the middle of the row. composer placeholder's job now («想聊什么都可以»). The line is drawn per visit
from three pools — daypart (morning / noon / afternoon / evening / late-night),
weekday (Mon, Fri, Sat, Sun only), and returning (accounts that already have a
session) — concatenated in that order and indexed by one random seed, with the
`{名字}` entries dropped when no name is set. 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 - **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 live session*. Nothing remounts when the first message lands, so an in-flight
draft, focus, or queued card survives the transition. Its width is draft, focus, or queued card survives the transition. Its width is
+3
View File
@@ -8,6 +8,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
对用户称「你」。口语化,但不轻佻。不加「亲爱的」,不每轮感谢信任,不用 emoji。 对用户称「你」。口语化,但不轻佻。不加「亲爱的」,不每轮感谢信任,不用 emoji。
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
## 五条原则 ## 五条原则
1. **先回应人,再展开方法。** 用户问婚姻,先用人话答方向,再进入度数和分盘。 1. **先回应人,再展开方法。** 用户问婚姻,先用人话答方向,再进入度数和分盘。
@@ -68,6 +70,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | | (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
| 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 | | 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 |
| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 |
## 服务端探针 → Agent 题干 ## 服务端探针 → Agent 题干
+2 -1
View File
@@ -721,7 +721,8 @@ export default function Home() {
); );
const onboardingFormActive = !profileComplete && onboardingStep !== "name"; const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : ""; const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection); const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
const conversationAnchor = useConversationScrollAnchor( const conversationAnchor = useConversationScrollAnchor(
conversation, conversation,
!rectificationSurfaceOpen && !starterHomeVisible, !rectificationSurfaceOpen && !starterHomeVisible,
+6 -10
View File
@@ -2089,29 +2089,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
padding: var(--space-8) 0 var(--space-5); padding: var(--space-8) 0 var(--space-5);
} }
/* The greeting: one line of salutation, one question. It used to be an h1 at /* The greeting: one line, two to five words. It used to be an h1 at
clamp(34px, 5vw, 52px) inside a bordered hero card, with two 132px product 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. */ cards under it — together about 800px sitting on top of the composer; then a
salutation line above a question. The question moved into the composer
placeholder, so there is no second line to size against. */
.starter-greeting-block { .starter-greeting-block {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
} }
.starter-salutation {
margin: 0 0 var(--space-2);
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.starter-greeting-block h1 { .starter-greeting-block h1 {
margin: 0; margin: 0;
color: var(--color-ink); color: var(--color-ink);
font-family: var(--font-display); font-family: var(--font-display);
font-size: clamp(26px, 3.2vw, 34px); font-size: clamp(24px, 2.8vw, 30px);
font-weight: 500; font-weight: 500;
letter-spacing: -.02em; letter-spacing: -.02em;
line-height: 1.3; line-height: 1.25;
text-wrap: balance; text-wrap: balance;
} }
+4 -3
View File
@@ -19,14 +19,15 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry";
*/ */
export type StarterGreetingProps = { export type StarterGreetingProps = {
readonly starterGreeting: { readonly salutation: string; readonly question: string }; readonly starterGreeting: { readonly text: string };
}; };
/* One line. The salutation/question pair it replaces asked the user's question
for them; the composer placeholder asks now. */
export function StarterGreeting({ starterGreeting }: StarterGreetingProps) { export function StarterGreeting({ starterGreeting }: StarterGreetingProps) {
return ( return (
<section className="starter-greeting-block" aria-labelledby="starter-heading"> <section className="starter-greeting-block" aria-labelledby="starter-heading">
<p className="starter-salutation">{starterGreeting.salutation}</p> <h1 id="starter-heading">{starterGreeting.text}</h1>
<h1 id="starter-heading">{starterGreeting.question}</h1>
</section> </section>
); );
} }
@@ -69,7 +69,7 @@ export function ordinaryComposerPlaceholder(input: {
return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"; return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…";
} }
if (input.creditsEmpty) return "余额不足,可在账户与点数中补充"; if (input.creditsEmpty) return "余额不足,可在账户与点数中补充";
return "例如:未来半年是否适合换工作?"; return "想聊什么都可以";
} }
export function rectificationOrdinaryComposerReopen(input: { export function rectificationOrdinaryComposerReopen(input: {
+61 -47
View File
@@ -1,72 +1,86 @@
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type GreetingVariant = { /**
readonly salutation: (name: string) => string; * One line, two to five words, no follow-up question.
readonly question: string; *
* The homepage opening used to be a salutation plus a question ("这么晚还醒着,
* {名字}。直接说说最在意的问题吧。") — an office voice that answered for the user
* before they had said anything. Asking is the composer placeholder's job now;
* the greeting only greets. No metaphors (星 / 月 / 陪), no emoji, no period.
*/
const NAME_TOKEN = "{名字}";
const periodGreetings: Record<GreetingPeriod, readonly string[]> = {
morning: ["早上好", "早上好,{名字}", "早呀,{名字}"],
noon: ["中午好", "中午好,{名字}"],
afternoon: ["下午好", "下午好,{名字}"],
evening: ["晚上好", "晚上好,{名字}", "今天过得怎么样?"],
"late-night": ["夜猫子,晚上好", "还没睡呀,{名字}", "晚安前再聊一会"],
}; };
const greetingVariants: Record<GreetingPeriod, readonly GreetingVariant[]> = { /** Keyed by `Date#getDay()`. Days with nothing worth saying stay out of the pool. */
morning: [ const weekdayGreetings: Readonly<Record<number, readonly string[]>> = {
{ salutation: (name) => `早上好,${name}。`, question: "今天最想先看什么?" }, 0: ["周日,慢一点"],
{ salutation: (name) => `${name},早安。`, question: "今天最该关注哪件事?" }, 1: ["周一了,{名字}"],
{ salutation: (name) => `早上好,${name}。`, question: "想从哪个问题开始?" }, 5: ["周五快乐"],
], 6: ["周末愉快,{名字}"],
noon: [
{ salutation: (name) => `中午好,${name}。`, question: "现在最想理清哪件事?" },
{ salutation: (name) => `${name},中午好。`, question: "什么问题最需要方向?" },
{ salutation: (name) => `午间好,${name}。`, question: "事业、关系或选择,想先聊哪个?" },
],
afternoon: [
{ salutation: (name) => `${name},下午好。`, question: "现在最想推进哪件事?" },
{ salutation: (name) => `下午好,${name}。`, question: "今天想先理清什么?" },
{ salutation: (name) => `${name},下午好。`, question: "事业、关系或选择,想先聊哪个?" },
],
evening: [
{ salutation: (name) => `晚上好,${name}。`, question: "今天最挂心的是哪件事?" },
{ salutation: (name) => `${name},晚上好。`, question: "此刻最想聊哪件事?" },
{ salutation: (name) => `晚上好,${name}。`, question: "把心里的问题告诉我吧。" },
],
"late-night": [
{ salutation: (name) => `夜深了,${name}。`, question: "此刻最想问什么?" },
{ salutation: (name) => `${name},还没休息吗?`, question: "想从哪件事说起?" },
{ salutation: (name) => `这么晚还醒着,${name}。`, question: "直接说说最在意的问题吧。" },
],
}; };
/** Only reachable once the account has at least one session behind it. */
const returningGreetings: readonly string[] = [
"又见面了,{名字}",
"欢迎回来",
"嗨,{名字}",
"最近还好吗,{名字}?",
];
function greetingPeriod(hour: number): GreetingPeriod {
if (hour >= 5 && hour < 11) return "morning";
if (hour >= 11 && hour < 14) return "noon";
if (hour >= 14 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "late-night";
}
/**
* Every line this moment can produce, in pool order: daypart, weekday, returning.
* A blank name drops the lines that would print an empty one; the daypart pool
* always keeps a nameless line, so the result is never empty.
*/
function starterGreetingPool(name: string, now: Date, hasSessions: boolean): readonly string[] {
const templates = [
...periodGreetings[greetingPeriod(now.getHours())],
...(weekdayGreetings[now.getDay()] ?? []),
...(hasSessions ? returningGreetings : []),
];
return name ? templates : templates.filter((template) => !template.includes(NAME_TOKEN));
}
export type StartGreetingParts = { export type StartGreetingParts = {
readonly salutation: string; readonly text: string;
readonly question: string;
}; };
export function createStartGreetingParts( export function createStartGreetingParts(
name: string, name: string,
now = new Date(), now = new Date(),
variantSelection = Math.random(), variantSelection = Math.random(),
hasSessions = false,
): StartGreetingParts { ): StartGreetingParts {
const displayName = name.trim() || "你好"; const displayName = name.trim();
const hour = now.getHours(); const pool = starterGreetingPool(displayName, now, hasSessions);
const period: GreetingPeriod = hour >= 5 && hour < 11 const index = Math.min(Math.max(Math.floor(variantSelection * pool.length), 0), pool.length - 1);
? "morning" return { text: pool[index]!.replaceAll(NAME_TOKEN, displayName) };
: hour >= 11 && hour < 14
? "noon"
: hour >= 14 && hour < 18
? "afternoon"
: hour >= 18 && hour < 23 ? "evening" : "late-night";
const variants = greetingVariants[period];
const index = Math.min(Math.max(Math.floor(variantSelection * variants.length), 0), variants.length - 1);
const variant = variants[index]!;
return { salutation: variant.salutation(displayName), question: variant.question };
} }
export function createStartGreeting( export function createStartGreeting(
name: string, name: string,
now = new Date(), now = new Date(),
variantSelection = Math.random(), variantSelection = Math.random(),
hasSessions = false,
): string { ): string {
const parts = createStartGreetingParts(name, now, variantSelection); return createStartGreetingParts(name, now, variantSelection, hasSessions).text;
return `${parts.salutation}${parts.question}`;
} }
export function createOnboardingFallbackGreeting(name: string): string { export function createOnboardingFallbackGreeting(name: string): string {
return `${name.trim()},从你此刻最关心的问题开始吧。`; return `欢迎,${name.trim()}`;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ test("a queued card renders above the live textarea", () => {
inputRef: createRef<HTMLTextAreaElement>(), inputRef: createRef<HTMLTextAreaElement>(),
value: "还想再问一句", value: "还想再问一句",
inputLabel: "输入你的问题", inputLabel: "输入你的问题",
placeholder: "例如:未来半年是否适合换工作?", placeholder: "想聊什么都可以",
maxLength: 500, maxLength: 500,
inputDisabled: false, inputDisabled: false,
submitLabel: "发送", submitLabel: "发送",
@@ -386,7 +386,9 @@ test("the starter heading is drawn per visit and the entry pills carry no fine p
const source = homeSurface; const source = homeSurface;
// Given: the heading reads from a variant seeded once per mount and redrawn when the home is left. // Given: the heading reads from a variant seeded once per mount and redrawn when the home is left.
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.question\}<\/h1>/); // 原值 `{starterGreeting.question}` / 新值 `{starterGreeting.text}` / 原因:开场语改成单行问候,
// 「招呼语 + 问句」两半的形状取消,heading 直接就是整行;「每次进首页重抽」这条意图没变。
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.text\}<\/h1>/);
assert.match(source, /useState\(\(\) => Math\.random\(\)\)/); assert.match(source, /useState\(\(\) => Math\.random\(\)\)/);
assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/); assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/);
@@ -426,7 +428,10 @@ test("the starter greeting keeps the display stack and display weight", () => {
assert.match(heading, /font-weight:\s*500\s*;/); assert.match(heading, /font-weight:\s*500\s*;/);
assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/); assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/);
// 问候不该再是 52px 的落地页大标题,它现在只是输入框上面的一行。 // 问候不该再是 52px 的落地页大标题,它现在只是输入框上面的一行。
assert.match(heading, /font-size:\s*clamp\(26px,/); // 原值 `clamp(26px,` / 新值 `clamp(24px,` / 原因:开场语从「招呼语 + 问句」两行收成单行问候,
// 不再需要 34px 上限那一档;上限同步降到 30px,断言仍然钉住「不是落地页大标题」这条意图。
assert.match(heading, /font-size:\s*clamp\(24px, 2\.8vw, 30px\);/);
assert.match(heading, /line-height:\s*1\.25;/);
}); });
test("the starter home hides technical chart parameters", () => { test("the starter home hides technical chart parameters", () => {
+140 -28
View File
@@ -8,59 +8,171 @@ import {
} from "../src/lib/starter-greeting.ts"; } from "../src/lib/starter-greeting.ts";
import { homeSurface } from "./home-surface.ts"; import { homeSurface } from "./home-surface.ts";
// 2026-07-19 是周日,20 周一,22 周三(星期池为空),24 周五,25 周六。
const SUNDAY = (hour: number) => new Date(2026, 6, 19, hour);
const MONDAY = (hour: number) => new Date(2026, 6, 20, hour);
const WEDNESDAY = (hour: number) => new Date(2026, 6, 22, hour);
const FRIDAY = (hour: number) => new Date(2026, 6, 24, hour);
test("terminal and fallback greetings follow the current profile name", () => { test("terminal and fallback greetings follow the current profile name", () => {
// 原值: 同时锁 onboardingRequestIdentity / isCurrentOnboardingRequest,避免建议问题串号。 // 原值: 同时锁 onboardingRequestIdentity / isCurrentOnboardingRequest,避免建议问题串号。
// 新值: 只保留问候语随当前称呼变化。 // 新值: 只保留问候语随当前称呼变化。
// 原因: 建议问题请求已删除,身份去重不再有消费点。 // 原因: 建议问题请求已删除,身份去重不再有消费点。
const terminalGreeting = createStartGreeting("周宁", new Date(2026, 6, 19, 8), 0); //
// 原值 `createStartGreeting("周宁", …, 0)` + `"周宁,从你此刻最关心的问题开始吧。"`
// / 新值 `createStartGreeting("周宁", …, 0.25)` + `"欢迎,周宁"`
// / 原因:时段池现在第一条是不带称呼的「早上好」,要取带称呼的条目得换下标;
// 兜底问候按产品口径改成单行「欢迎,{名字}」,不再追问。断言强度没变:
// 两条都仍然必须带当前称呼、不得带旧称呼。
const terminalGreeting = createStartGreeting("周宁", SUNDAY(8), 0.25);
const fallbackGreeting = createOnboardingFallbackGreeting("周宁"); const fallbackGreeting = createOnboardingFallbackGreeting("周宁");
for (const greeting of [terminalGreeting, fallbackGreeting]) { for (const greeting of [terminalGreeting, fallbackGreeting]) {
assert.match(greeting, /周宁/); assert.match(greeting, /周宁/);
assert.doesNotMatch(greeting, /林遥/); assert.doesNotMatch(greeting, /林遥/);
} }
assert.equal(fallbackGreeting, "周宁,从你此刻最关心的问题开始吧。"); assert.equal(terminalGreeting, "早上好,周宁");
assert.equal(fallbackGreeting, "欢迎,周宁");
}); });
test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => { test("the starter greeting is one line drawn from daypart, weekday and returning pools", () => {
// Given: the same daypart variant, asked for as parts and as one line. // 原值:`parts.salutation` = "中午好,周宁。"、`parts.question` = "现在最想理清哪件事?",
const noon = new Date(2026, 6, 19, 12); // 并断言两半拼起来等于整行、每个时段至少 12 条不同问句。
const parts = createStartGreetingParts("周宁", noon, 0); // 新值:`parts.text` 就是整行问候;断言时段池按下标取到「中午好」/「中午好,周宁」。
const whole = createStartGreeting("周宁", noon, 0); // 原因:产品把开场语改成 claude.ai 式的单行问候,追问句移到输入框占位符,
// 「招呼语 + 问句」两半这个形状本身被取消,问句池不再存在。
const noon = WEDNESDAY(12);
assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好");
assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "中午好,周宁");
assert.equal(createStartGreeting("周宁", noon, 0.5), createStartGreetingParts("周宁", noon, 0.5).text);
// Then: the salutation carries the name and the question carries no name, and they recompose exactly. // 边界不越界:1 与 0.999999 都落在池内最后一条。
assert.equal(parts.salutation, "中午好,周宁。"); assert.equal(createStartGreetingParts("周宁", noon, 1).text, "中午好,周宁");
assert.equal(parts.question, "现在最想理清哪件事?"); assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "中午好,周宁");
assert.equal(`${parts.salutation}${parts.question}`, whole);
assert.doesNotMatch(parts.question, /周宁/);
// And: the extremes stay inside the variant list rather than falling off the end.
assert.equal(createStartGreetingParts("周宁", noon, 1).question, "事业、关系或选择,想先聊哪个?");
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).question, "事业、关系或选择,想先聊哪个?");
// And: every daypart is reachable, so the heading is not one fixed line in disguise.
const questions = new Set([0, 8, 12, 16, 20, 23].flatMap((hour) =>
[0, 1 / 3, 2 / 3].map((selection) =>
createStartGreetingParts("周宁", new Date(2026, 6, 19, hour), selection).question)));
assert.ok(questions.size >= 12);
}); });
test("the starter hero stays at a salutation and a question", () => { test("late night has its own three lines and none of them asks a follow-up", () => {
const lateNight = WEDNESDAY(2);
assert.equal(createStartGreetingParts("周宁", lateNight, 0).text, "夜猫子,晚上好");
assert.equal(createStartGreetingParts("周宁", lateNight, 1 / 3).text, "还没睡呀,周宁");
assert.equal(createStartGreetingParts("周宁", lateNight, 0.7).text, "晚安前再聊一会");
// 23 点与 4 点同属深夜。
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(23), 1 / 3).text, "还没睡呀,周宁");
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(4), 1 / 3).text, "还没睡呀,周宁");
});
test("a blank name leaves only the lines that carry no name", () => {
const lateNight = WEDNESDAY(2);
assert.equal(createStartGreetingParts("", lateNight, 0).text, "夜猫子,晚上好");
assert.equal(createStartGreetingParts(" ", lateNight, 0.9).text, "晚安前再聊一会");
// 周一的星期条目带称呼,名字为空时整条不可达;回访池同理。
for (let step = 0; step < 100; step += 1) {
for (const hasSessions of [false, true]) {
const text = createStartGreetingParts("", MONDAY(9), step / 100, hasSessions).text;
assert.doesNotMatch(text, /周一了/);
assert.doesNotMatch(text, /又见面了|嗨,|最近还好吗/);
assert.ok(text.length > 0);
assert.doesNotMatch(text, /\{名字\}/);
assert.doesNotMatch(text, /,$|^,/);
}
}
});
test("the weekday pool only opens on its own day", () => {
const friday = FRIDAY(9);
const fridayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", friday, step / 100).text));
assert.ok(fridayLines.has("周五快乐"));
const wednesdayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", WEDNESDAY(9), step / 100).text));
assert.ok(!wednesdayLines.has("周五快乐"));
assert.equal(wednesdayLines.size, 3);
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", MONDAY(9), step / 100).text)).has("周一了,周宁"));
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", SUNDAY(9), step / 100).text)).has("周日,慢一点"));
});
test("the returning pool needs at least one session behind the account", () => {
const evening = WEDNESDAY(20);
const returning = ["又见面了,周宁", "欢迎回来", "嗨,周宁", "最近还好吗,周宁?"];
const firstVisit = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", evening, step / 100, false).text));
for (const line of returning) assert.ok(!firstVisit.has(line), `first visit must not reach ${line}`);
assert.equal(firstVisit.size, 3);
const returnVisit = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", evening, step / 100, true).text));
for (const line of returning) assert.ok(returnVisit.has(line), `return visit must reach ${line}`);
assert.equal(returnVisit.size, 7);
});
test("the same inputs always produce the same line", () => {
for (const hour of [2, 8, 12, 16, 20]) {
for (const hasSessions of [false, true]) {
for (const step of [0, 17, 42, 99]) {
const args = [hour, step / 100, hasSessions] as const;
const first = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]);
const second = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]);
assert.equal(first, second);
}
}
}
});
test("no greeting line uses a metaphor or trails off into a sentence", () => {
for (let day = 19; day <= 25; day += 1) {
for (let hour = 0; hour < 24; hour += 1) {
for (const name of ["周宁", ""]) {
for (const hasSessions of [false, true]) {
for (let step = 0; step < 100; step += 1) {
const text = createStartGreetingParts(
name,
new Date(2026, 6, day, hour),
step / 100,
hasSessions,
).text;
assert.doesNotMatch(text, /[星月陪]/, `metaphor in ${text}`);
assert.doesNotMatch(text, /。$/, `sentence period in ${text}`);
assert.ok(text.length > 0);
assert.ok(text.length <= 12, `${text} is longer than one short line`);
}
}
}
}
}
});
test("the starter hero is one heading and nothing else", () => {
const source = homeSurface; const source = homeSurface;
// Given: the hero heading is the question half of the time-aware greeting.
// 原值 `className="starter-greeting"` / 新值 `className="starter-salutation"` // 原值 `className="starter-greeting"` / 新值 `className="starter-salutation"`
// / 原因:`.starter-greeting` 这个类名此前既指招呼语那一行、又被读成整块问候区, // / 原因:`.starter-greeting` 这个类名此前既指招呼语那一行、又被读成整块问候区,
// 拆成 `.starter-greeting-block`(容器)与 `.starter-salutation`(招呼语)后不再歧义。 // 拆成 `.starter-greeting-block`(容器)与 `.starter-salutation`(招呼语)后不再歧义。
// 「两行:招呼语 + 问句」这个意图本身没变。 //
assert.match(source, /<p className="starter-salutation">\{starterGreeting\.salutation\}<\/p>/); // 原值:断言 `<p className="starter-salutation">` 与 `<h1>{starterGreeting.question}</h1>` 同时存在
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.question\}<\/h1>/); // / 新值:断言只剩 `<h1 id="starter-heading">{starterGreeting.text}</h1>`,且 `starter-salutation` 在源码与样式里都消失
// / 原因:开场语改成单行问候,副标题那一行连同它的样式一起下线;断言从「两行」改成「一行且不得有第二行」,强度不降反升。
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.text\}<\/h1>/);
assert.doesNotMatch(source, /starter-salutation/);
assert.doesNotMatch(source, /starterGreeting\.(salutation|question)/);
// Then: no third line repeats the welcome the two lines above already carry. // Then: no third line repeats the welcome the heading already carries.
assert.doesNotMatch(source, /starter-hero-note/); assert.doesNotMatch(source, /starter-hero-note/);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.doesNotMatch(styles, /starter-hero-note/); assert.doesNotMatch(styles, /starter-hero-note/);
assert.doesNotMatch(styles, /starter-salutation/);
// And: the separate static heading pool is gone for good. // And: the separate static heading pool is gone for good.
assert.doesNotMatch(source, /starterPrompt|createStarterPrompt|greetingForHour/); assert.doesNotMatch(source, /starterPrompt|createStarterPrompt|greetingForHour/);
assert.doesNotMatch(source, /greeting: createStartGreeting\(/); assert.doesNotMatch(source, /greeting: createStartGreeting\(/);
}); });
test("the greeting knows whether the account already has sessions", () => {
// hasSessions 必须来自已有的会话列表变量,不得新开一份状态。
assert.match(homeSurface, /const starterHasPriorSessions = sessions\.some\(/);
assert.match(homeSurface, /createStartGreetingParts\(profile\.name, new Date\(\), starterGreetingSelection, starterHasPriorSessions\)/);
});