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:
co-authored by
Claude Opus 5.5
parent
6d371d82a7
commit
b5a02ca861
@@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { loadBillingPanel } from "@/lib/billing-panel-loader";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Coins } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
@@ -1210,7 +1210,7 @@ export default function Home() {
|
||||
<div className="chat-header-actions">
|
||||
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
|
||||
<button className="credit-button" type="button" onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
<Coins className="credit-icon" aria-hidden="true" />
|
||||
<span>{account ? account.credits : "—"}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1244,7 +1244,7 @@ export default function Home() {
|
||||
) : null}
|
||||
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
|
||||
<StarterGreeting starterGreeting={starterGreeting} />
|
||||
<StarterGreeting starterGreeting={starterGreeting} dailyTrend={natalMinuteAvailable ? dailyStarlanguageTrend : null} dailyTrendSettled={dailyStarlanguage.kind === "ready"} />
|
||||
)}
|
||||
</div>
|
||||
) : sessionMessagesLoading ? (
|
||||
@@ -1328,13 +1328,10 @@ export default function Home() {
|
||||
{starterHomeVisible && (
|
||||
<StarterEntries
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
dailyStarlanguage={dailyStarlanguage}
|
||||
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
|
||||
dailyStarlanguageTrend={dailyStarlanguageTrend}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
|
||||
rectificationCardLabel={rectificationCardLabel}
|
||||
rectificationCardAction={rectificationCardAction}
|
||||
rectificationLoading={rectificationLoading}
|
||||
rectificationMutationPending={rectificationMutationPending}
|
||||
openRectificationFromHomepage={openRectificationFromHomepage}
|
||||
|
||||
@@ -2258,6 +2258,23 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Today's trend, one quiet line under the greeting (2026-09-26). It used to be
|
||||
glued to the rectification hint under the pills with a middle dot. Two lines
|
||||
at most: the trend is model-written and can run long on a phone. */
|
||||
.starter-greeting-sub {
|
||||
display: -webkit-box;
|
||||
max-width: 560px;
|
||||
margin: var(--space-2) auto 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Clock3, Sparkles } from "lucide-react";
|
||||
import { Clock3, MoonStar } from "lucide-react";
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import type { DailyStarlanguageState } from "@/lib/home-types";
|
||||
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
|
||||
/**
|
||||
@@ -21,27 +20,50 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
|
||||
export type StarterGreetingProps = {
|
||||
readonly starterGreeting: { readonly text: string };
|
||||
/* Today's trend, one quiet line under the greeting. `null` hides it (no natal
|
||||
minute). Before the card is ready the caller passes the static sentence,
|
||||
never a placeholder or a spinner. */
|
||||
readonly dailyTrend?: string | null;
|
||||
readonly dailyTrendSettled?: boolean;
|
||||
};
|
||||
|
||||
/* 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) {
|
||||
for them; the composer placeholder asks now. The optional sub-line is today's
|
||||
trend, moved up from the fine print under the entry pills where it was glued
|
||||
to an unrelated rectification hint with a middle dot. */
|
||||
export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrendSettled = true }: StarterGreetingProps) {
|
||||
return (
|
||||
<section className="starter-greeting-block" aria-labelledby="starter-heading">
|
||||
<h1 id="starter-heading">{starterGreeting.text}</h1>
|
||||
{dailyTrend ? (
|
||||
<p className="starter-greeting-sub" role={dailyTrendSettled ? undefined : "status"}>
|
||||
{dailyTrend}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* The line under the pills only appears when there is something to do about
|
||||
it. First runs and finished cases say nothing: the pill already names the
|
||||
action. The branch reads the entry summary itself, not the derived
|
||||
start/restart label, and a non-self subject wins because the case summary
|
||||
belongs to the account owner. */
|
||||
export function rectificationEntryHint(
|
||||
rectificationForSelf: boolean,
|
||||
summary: RectificationEntrySummary | null,
|
||||
): string | null {
|
||||
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
|
||||
if (summary?.hasResumableCase) return "上次那次校正还没完成,可以接着做。";
|
||||
return null;
|
||||
}
|
||||
|
||||
export type StarterEntriesProps = {
|
||||
readonly dailyStarlanguage: DailyStarlanguageState;
|
||||
readonly natalMinuteAvailable: boolean;
|
||||
readonly dailyStarlanguageQuestion: string;
|
||||
readonly dailyStarlanguageTrend: string;
|
||||
readonly productEntrypointsDisabled: boolean;
|
||||
readonly startDailyStarlanguageConsultation: () => void;
|
||||
readonly rectificationCardLabel: string;
|
||||
readonly rectificationCardAction: string;
|
||||
readonly rectificationLoading: boolean;
|
||||
readonly rectificationMutationPending: boolean;
|
||||
readonly openRectificationFromHomepage: () => void;
|
||||
@@ -53,13 +75,10 @@ export type StarterEntriesProps = {
|
||||
|
||||
export function StarterEntries({
|
||||
natalMinuteAvailable,
|
||||
dailyStarlanguage,
|
||||
dailyStarlanguageQuestion,
|
||||
dailyStarlanguageTrend,
|
||||
productEntrypointsDisabled,
|
||||
startDailyStarlanguageConsultation,
|
||||
rectificationCardLabel,
|
||||
rectificationCardAction,
|
||||
rectificationLoading,
|
||||
rectificationMutationPending,
|
||||
openRectificationFromHomepage,
|
||||
@@ -71,20 +90,7 @@ export function StarterEntries({
|
||||
const subjectId = useCurrentSubject();
|
||||
const rectificationForSelf = subjectId === "self";
|
||||
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 = !rectificationForSelf
|
||||
? "生时校正暂时只支持本人"
|
||||
: rectificationEntrySummary?.hasResumableCase
|
||||
? "未完成的那次可以从左侧继续"
|
||||
: rectificationCardAction === "restart"
|
||||
? "上次已经校正完,可以拿最新资料再来一次"
|
||||
: "不确定出生时间时,用记得住的经历一步步缩小范围";
|
||||
const rectificationHint = rectificationEntryHint(rectificationForSelf, rectificationEntrySummary);
|
||||
|
||||
return (
|
||||
<div className="starter-entries">
|
||||
@@ -96,7 +102,7 @@ export function StarterEntries({
|
||||
disabled={productEntrypointsDisabled}
|
||||
onClick={startDailyStarlanguageConsultation}
|
||||
>
|
||||
<Sparkles aria-hidden="true" />
|
||||
<MoonStar aria-hidden="true" />
|
||||
<span>{dailyLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -111,12 +117,7 @@ export function StarterEntries({
|
||||
<span>生时校正</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="starter-entry-hint"
|
||||
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
|
||||
>
|
||||
{dailyHint} · {rectificationHint}
|
||||
</p>
|
||||
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
|
||||
{!natalMinuteAvailable && (
|
||||
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
|
||||
)}
|
||||
|
||||
@@ -69,32 +69,45 @@ export function isTerminalRectificationStatus(status: string): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/* `GET /api/rectification/cases/entry-summary` answers with the camelCase
|
||||
object `getRectificationEntrySummary` returns; only the RPC underneath speaks
|
||||
snake_case. This parser used to read the snake_case keys alone, so every real
|
||||
response parsed as "nothing on record" and the homepage never saw a resumable
|
||||
or finished case (BUG-1041). The route's camelCase shape wins; snake_case is
|
||||
still read so an RPC-shaped payload does not silently collapse either. */
|
||||
function field(row: Record<string, unknown>, camel: string, snake: string): unknown {
|
||||
return camel in row ? row[camel] : row[snake];
|
||||
}
|
||||
|
||||
function objectField(row: Record<string, unknown>, camel: string, snake: string): Record<string, unknown> | null {
|
||||
const value = field(row, camel, snake);
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
|
||||
}
|
||||
const row = value as Record<string, unknown>;
|
||||
const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object"
|
||||
? row.latest_resumable as Record<string, unknown>
|
||||
: null;
|
||||
const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object"
|
||||
? row.latest_terminal as Record<string, unknown>
|
||||
: null;
|
||||
const latestResumable = objectField(row, "latestResumable", "latest_resumable");
|
||||
const latestTerminal = objectField(row, "latestTerminal", "latest_terminal");
|
||||
const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null;
|
||||
const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null;
|
||||
return {
|
||||
hasResumableCase: row.has_resumable_case === true,
|
||||
hasTerminalCaseWithTime: row.has_terminal_case_with_time === true,
|
||||
latestResumable: latestResumable && typeof latestResumable.case_id === "string"
|
||||
hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true,
|
||||
hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true,
|
||||
latestResumable: latestResumable && typeof resumableCaseId === "string"
|
||||
? {
|
||||
caseId: latestResumable.case_id,
|
||||
caseId: resumableCaseId,
|
||||
status: String(latestResumable.status ?? ""),
|
||||
lastActivityAt: String(latestResumable.last_activity_at ?? ""),
|
||||
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
|
||||
}
|
||||
: null,
|
||||
latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string"
|
||||
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
|
||||
? {
|
||||
caseId: latestTerminal.case_id,
|
||||
caseId: terminalCaseId,
|
||||
status: String(latestTerminal.status ?? ""),
|
||||
hasUsableTime: latestTerminal.has_usable_time === true,
|
||||
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user