fix(home): split starter fine print, show hints only when actionable (BUG-1041)

TASK-starter-home-polish-20260926:
- D1 today's trend moves to a sub-line under the greeting
  (.starter-greeting-sub), read from the same dailyStarlanguage state the
  warm snapshot seeds; static sentence until ready, hidden without a minute.
- D2 the line under the pills appears only for a resumable case or a
  non-self subject; the first-run and redo sentences are removed.
- BUG-1041 entrySummaryFromResponse read snake_case keys while the
  entry-summary route returns camelCase, so every account parsed as a
  first run; the parser now reads the route's shape (snake_case still ok).
- D3 daily entry icon MoonStar, credits icon Coins; D4 boundary line kept.
- Records: CHANGELOG, DESIGN, VOICE, BUG_HISTORY, PROGRESS, testing list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 08:47:53 +08:00
co-authored by Claude Opus 5.5
parent 6d371d82a7
commit b5a02ca861
16 changed files with 427 additions and 68 deletions
+7 -3
View File
@@ -6,10 +6,14 @@ const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", i
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("the 2026-09-22 icon audit keeps the existing entry icons and adds no loop", () => {
assert.match(starterHome, /import \{ Clock3, Sparkles \} from "lucide-react"/);
assert.match(starterHome, /<Sparkles aria-hidden="true" \/>/);
// 原值:`import { Clock3, Sparkles }`、`<Sparkles aria-hidden="true" />`,且 doesNotMatch /Moon|MoonStar|thinking-orbs/
// 新值:`import { Clock3, MoonStar }`、`<MoonStar aria-hidden="true" />`,doesNotMatch 收窄为 /Sparkles|thinking-orbs/
// 原因:TASK-starter-home-polish-20260926 D3 产品拍板今日星语入口改 lucide MoonStar(与右上角点数不再同图标);
// 「不加循环动画、不引入 thinking-orbs」这条意图原样保留,并反向锁住旧图标不回来。
assert.match(starterHome, /import \{ Clock3, MoonStar \} from "lucide-react"/);
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
assert.match(starterHome, /<Clock3 aria-hidden="true" \/>/);
assert.doesNotMatch(starterHome, /Moon|MoonStar|thinking-orbs/);
assert.doesNotMatch(starterHome, /Sparkles|thinking-orbs/);
const blockStart = css.indexOf(".starter-entry {");
const blockEnd = css.indexOf(".starter-entry > svg");
const block = css.slice(blockStart, blockEnd);
@@ -919,7 +919,14 @@ test("homepage CTA is server-driven from the entry summary, not the session list
assert.doesNotMatch(page, /hasRectificationSession/);
assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/);
assert.match(page, /resolveRectificationEntryAction/);
assert.match(page, /rectificationCardAction === "restart"/);
// 原值:assert.match(page, /rectificationCardAction === "restart"/)
// 新值:首页小字改由 rectificationEntryHint 直接读 entry summary 的 hasResumableCase;
// 「restart」分支对应的那句已删除,改锁 hint 读的是 summary 本身、不读会话列表
// 原因:TASK-starter-home-polish-20260926 D2 删掉「上次已经校正完…」一句;BUG-1041 查明
// `=== "restart"` 从未命中(解析器读错键名)。「CTA 与提示由服务端 entry summary 驱动」这条意图不变。
assert.match(page, /rectificationEntryHint\(rectificationForSelf, rectificationEntrySummary\)/);
assert.match(page, /summary\?\.hasResumableCase/);
assert.doesNotMatch(page, /rectificationCardAction === "restart"/);
assert.match(entryLib, /开始新的生时校正/);
assert.match(entryLib, /再次校正/);
});
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
StarterEntries,
StarterGreeting,
rectificationEntryHint,
type StarterEntriesProps,
} from "../src/components/starter-home.tsx";
import { setCurrentSubject } from "../src/lib/current-subject.ts";
import { getRectificationEntrySummary } from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
entrySummaryFromResponse,
resolveRectificationEntryAction,
type RectificationEntrySummary,
} from "../src/lib/rectification-entry.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const CASE_ID = "00000000-0000-4000-8000-000000000041";
const RESUMABLE_HINT = "上次那次校正还没完成,可以接着做。";
const NON_SELF_HINT = "生时校正暂时只支持本人。";
const REMOVED_HINTS = /不确定出生时间时|上次已经校正完|未完成的那次可以从左侧继续|看看今天的整体节奏| · /;
const summaries = {
firstRun: { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null },
rectified: {
hasResumableCase: false,
hasTerminalCaseWithTime: true,
latestResumable: null,
latestTerminal: { caseId: CASE_ID, status: "confirmed", hasUsableTime: true },
},
resumable: {
hasResumableCase: true,
hasTerminalCaseWithTime: true,
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
latestTerminal: null,
},
} satisfies Record<string, RectificationEntrySummary>;
function entriesProps(overrides: Partial<StarterEntriesProps> = {}): StarterEntriesProps {
return {
natalMinuteAvailable: true,
dailyStarlanguageQuestion: "深入看今日",
productEntrypointsDisabled: false,
startDailyStarlanguageConsultation: () => undefined,
rectificationCardLabel: "开始新的生时校正",
rectificationLoading: false,
rectificationMutationPending: false,
openRectificationFromHomepage: () => undefined,
rectificationEntrySummary: null,
rectificationError: "",
rectificationSurfaceOpen: false,
rectificationErrorMessage: "",
...overrides,
};
}
function hintOf(markup: string): string | null {
return markup.match(/<p class="starter-entry-hint">([^<]*)<\/p>/)?.[1] ?? null;
}
test("D2: the line under the pills only appears when the user has something to do", () => {
// First run and an already-rectified account: no hint at all.
for (const summary of [null, summaries.firstRun, summaries.rectified]) {
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summary })} />);
assert.equal(hintOf(markup), null, JSON.stringify(summary));
assert.doesNotMatch(markup, /starter-entry-hint/);
assert.doesNotMatch(markup, REMOVED_HINTS);
}
// An unfinished case: one sentence, the resumable one.
const resumable = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
assert.equal(hintOf(resumable), RESUMABLE_HINT);
assert.doesNotMatch(resumable, REMOVED_HINTS);
});
test("D2: a non-self subject gets the self-only sentence, even with an unfinished case on record", async () => {
assert.equal(rectificationEntryHint(false, null), NON_SELF_HINT);
assert.equal(rectificationEntryHint(false, summaries.resumable), NON_SELF_HINT);
assert.equal(rectificationEntryHint(true, summaries.resumable), RESUMABLE_HINT);
assert.equal(rectificationEntryHint(true, summaries.rectified), null);
assert.equal(rectificationEntryHint(true, summaries.firstRun), null);
assert.equal(rectificationEntryHint(true, null), null);
// Rendered through the real subject store, not just the helper.
const harness = createClientLifecycleHarness();
try {
setCurrentSubject("person-fictional-1");
await harness.render(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
const hints = harness.elements().filter((node) => node.getAttribute("class") === "starter-entry-hint");
assert.equal(hints.length, 1);
assert.equal(hints[0].text, NON_SELF_HINT);
const rectificationPill = harness.elements().find((node) => node.getAttribute("aria-label") === "开始新的生时校正");
assert.equal(rectificationPill?.disabled, true);
} finally {
setCurrentSubject("self");
await harness.close();
}
assert.deepEqual(harness.errors, []);
});
test("D4: the no-minute boundary sentence stays under the pills", () => {
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ natalMinuteAvailable: false })} />);
assert.match(markup, /<p class="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。<\/p>/);
assert.doesNotMatch(markup, REMOVED_HINTS);
const withMinute = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
assert.doesNotMatch(withMinute, /starter-entry-boundary/);
});
test("D1: today's trend is a sub-line under the greeting, static until ready, hidden without a minute", () => {
const ready = renderToStaticMarkup(
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="虚构的今日趋势句。" dailyTrendSettled />,
);
assert.match(ready, /<h1 id="starter-heading">早上好<\/h1><p class="starter-greeting-sub">虚构的今日趋势句。<\/p>/);
assert.doesNotMatch(ready, /role="status"|aria-busy/);
const pending = renderToStaticMarkup(
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="今天的星语还没写出来。" dailyTrendSettled={false} />,
);
assert.match(pending, /<p class="starter-greeting-sub" role="status">今天的星语还没写出来。<\/p>/);
assert.doesNotMatch(pending, /aria-busy|spinner|inline-spin|正在加载/);
for (const markup of [
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend={null} />),
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} />),
]) {
assert.equal(markup, '<section class="starter-greeting-block" aria-labelledby="starter-heading"><h1 id="starter-heading">早上好</h1></section>');
}
// The trend no longer rides the pills' fine print.
const entries = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
assert.doesNotMatch(entries, /今天的星语还没写出来/);
});
test("D1: page.tsx feeds the sub-line from the same daily state the warm snapshot seeds", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /useState<DailyStarlanguageState>\(\(\) => warmStart\(\)\?\.daily \?\? \{ kind: "pending" \}\)/);
assert.match(page, /<StarterGreeting starterGreeting=\{starterGreeting\} dailyTrend=\{natalMinuteAvailable \? dailyStarlanguageTrend : null\} dailyTrendSettled=\{dailyStarlanguage\.kind === "ready"\} \/>/);
assert.doesNotMatch(page, /dailyStarlanguageTrend=\{/);
});
test("D3: daily entry uses MoonStar, credits use Coins, accessible names unchanged", () => {
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
assert.doesNotMatch(starterHome, /Sparkles/);
assert.match(page, /import \{ Coins \} from "lucide-react";/);
assert.match(page, /<Coins className="credit-icon" aria-hidden="true" \/>/);
assert.doesNotMatch(page, /Sparkles/);
assert.match(page, /aria-label=\{account \? `余额 \$\{account\.credits\} 点,会员与充值` : accountError \|\| "读取余额中"\}/);
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
assert.match(markup, /aria-label="深入看今日"/);
assert.match(markup, /lucide-moon-star/);
});
test("BUG-1041: the homepage parser reads what the entry-summary route actually returns", async () => {
// The route returns NextResponse.json(await getRectificationEntrySummary(...)), i.e. the camelCase
// projection of the snake_case RPC row. Round-trip it through JSON the way the browser receives it.
const accounting = {
rpc: async (fn: string) => {
assert.equal(fn, "get_agentic_rectification_entry_summary");
return {
data: {
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: { case_id: CASE_ID, status: "confirmed", has_usable_time: true },
},
error: null,
};
},
} as unknown as Parameters<typeof getRectificationEntrySummary>[0];
const wire = JSON.parse(JSON.stringify(await getRectificationEntrySummary(accounting, "user-fictional")));
assert.equal("has_terminal_case_with_time" in wire, false, "the route speaks camelCase");
const summary = entrySummaryFromResponse(wire);
assert.equal(summary.hasTerminalCaseWithTime, true);
assert.equal(summary.latestTerminal?.caseId, CASE_ID);
assert.equal(summary.latestTerminal?.hasUsableTime, true);
assert.equal(resolveRectificationEntryAction(summary), "restart");
const resumableWire = JSON.parse(JSON.stringify({
hasResumableCase: true,
hasTerminalCaseWithTime: false,
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
latestTerminal: null,
}));
const resumable = entrySummaryFromResponse(resumableWire);
assert.equal(resumable.hasResumableCase, true);
assert.equal(resumable.latestResumable?.lastActivityAt, "2026-09-26T00:00:00.000Z");
assert.equal(rectificationEntryHint(true, resumable), RESUMABLE_HINT);
});