From d7e8cc46c238ac0850ba3a81d71b4f55a5906a12 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 01:23:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=BA=E7=89=A9=E7=9B=AE=E5=BD=95=E6=B2=A1?= =?UTF-8?q?=E5=88=B0=E6=97=B6=E4=B8=8D=E5=86=8D=E5=BD=93=E6=88=90=E6=B2=A1?= =?UTF-8?q?=E5=BB=BA=E5=AE=8C=E6=A1=A3=EF=BC=88U11=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目录还没回来时不闪「看谁的盘」,热启动也不因此走冷路径。已知是空的,仍然算没建完档。月亮星座和天空副句的上升,首页没有现成资料,不新发请求,也不编星座。 Co-Authored-By: Grok (agent loop) --- ...PROGRESS-ui-kit-onboarding-fix-20261026.md | 18 +++++++ docs/tasks/README.md | 2 +- frontend/src/app/(app)/page.tsx | 48 +++++++++++-------- .../birth-sky/birth-sky-converge.tsx | 6 +-- frontend/src/lib/home-bootstrap-run.ts | 21 ++++++-- frontend/src/lib/home-profile.ts | 17 +++++++ frontend/src/lib/home-warm-start.ts | 12 ++--- frontend/src/lib/session-list-context.tsx | 3 +- frontend/src/lib/starter-questions.ts | 8 ++++ .../birth-time-consultation-consent.test.ts | 8 ++-- frontend/tests/chat-composer-queue.test.ts | 8 ++-- .../tests/composer-isolation-contract.test.ts | 10 ++-- .../tests/home-first-load-20260930.test.tsx | 39 +++++++++++++++ frontend/tests/home-warm-snapshot.test.ts | 3 ++ .../tests/onboarding-intake-20261022.test.ts | 15 +++++- 15 files changed, 168 insertions(+), 50 deletions(-) diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index d8bd0d3b..5dd738f7 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -85,3 +85,21 @@ | 检查 | 命令 | 结果 | | --- | --- | --- | | 这六处加上登录动作 | `node node_modules/tsx/dist/cli.mjs --test` 盘型、导出、星盘页、账户、外观、登录、登录动作 | 62 通过,0 失败,0 取消,退出码 0 | + +## U11 · 人物目录没到时不当成没建完档 + +没有新的产品故障,所以没有新的 BUG 编号。这三处里,目录这一处已经按「未知」处理。月亮星座和天空副句的星座,首页手里没有现成资料,不能新发请求,也不能编一个星座。 + +人物目录还没回来时,不再把它当成「没有别人」。只有名字、盘在别人名下的账户,不会在揭幕后闪出「看谁的盘」,热启动也不会因此改走冷路径。目录确实是空的,仍然算没建完档。揭幕会等到目录回来再显示,所以不会先闪一屏再改口。 + +起步第一问的「只有月亮」那一档,句子本来就会写。星座目前只从今日星语记住。没有出生分钟时,今日星语不请求。账户、档案、本命盘缓存里都没有月亮星座。星盘页那份结果要有分钟才会有,而且上升和月亮一起都有,对不上「只有月亮」。按任务书不能为了这一问再发请求,也不能临时编一个星座。所以这一档在第一次进首页、又没有分钟时,仍然走不到。有分钟时,今日星语回来后,上升和月亮两档照旧会写上。 + +帮别人看的天空副句,句子是「那一刻,{名字}的上升在{星座}」。名字在,上升星座不在,这一行就不显示,原来的天空句子照常播。上升同样只在已有的星座记录里,没有就空着。没有为了这一行再排一次盘。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 目录未知、已知空列表、副句,以及被改到的首页合同 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test`:`home-warm-snapshot`、`onboarding-intake-20261022`、`home-first-load-20260930`、`home-bootstrap-run`、`composer-isolation-contract`、`chat-composer-queue`、`birth-time-consultation-consent`、`home-shell-growth-contract` | 75 通过,0 失败,0 取消,退出码 0 | + +断言三栏(引导页是否预载):原值是目录还没到也按空列表预载。新值是已知空列表才预载,未知不预载、步骤停在称呼。原因是未知不能当成没建完档。 + +断言三栏(输入框标签和禁用):原值认 `onboardingStep`。新值认 `shownOnboardingStep`,并在目录未到时不收起输入框。原因同上。称呼和发送的两句文案没改。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e07237a7..f189efbc 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -40,7 +40,7 @@ | `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | -| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10 已提交) | `codex/ui-kit-onboarding-fix-20261026` | +| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10、U11 已提交) | `codex/ui-kit-onboarding-fix-20261026` | | —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` | | `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index f20d7706..1a192618 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -43,8 +43,8 @@ import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration"; import { useSessionList } from "@/hooks/use-session-list"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; -import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject"; -import { setOnboardingFinishedListener } from "@/lib/onboarding-intake"; +import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject"; +import { readOnboardingIntake, setOnboardingFinishedListener } from "@/lib/onboarding-intake"; import { useSessionStore } from "@/stores/session-store"; import { useShallow } from "zustand/react/shallow"; import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals"; @@ -153,6 +153,7 @@ import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog"; import { birthQuestion, isProfileComplete, + missingProfileStep, placeQuestion, readProfile, selfChartComplete, @@ -349,6 +350,7 @@ export default function Home() { () => peekSubjectCatalog() ?? EMPTY_SUBJECT_CATALOG, () => EMPTY_SUBJECT_CATALOG, ); + const subjectCatalogReady = useCurrentSubjectReady(); const currentSubjectId = useCurrentSubject(); const starterSignMap = useSyncExternalStore(subscribeStarterSigns, readStarterSignMap, readStarterSignMap); const { @@ -660,7 +662,15 @@ export default function Home() { setChartLibrary((current) => upsertSelfChart(current, profileForLibrary)); }, [account, accountId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesDegraded, markChatProfilesReady, profile]); - const profileComplete = isProfileComplete(profile, subjectCatalog); + // An empty stand-in is not a known empty list. Until the people list arrives, + // do not treat a name-only holder as unfinished (that flashes 「看谁的盘」). + const profileComplete = subjectCatalogReady && isProfileComplete(profile, subjectCatalog); + const catalogStep = subjectCatalogReady + ? missingProfileStep(profile, subjectCatalog, accountId ? readOnboardingIntake(accountId) : null) + : null; + const shownOnboardingStep = onboardingStep === "name" && catalogStep && catalogStep !== "name" + ? catalogStep + : onboardingStep; const dailyProfile = useCurrentSubjectProfile(profile); const birthTimeRoute = resolveBirthTimeConsultationRoute(dailyProfile ?? { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, birthTimeConsultationConsent, activeSessionId); const natalMinuteAvailable = birthTimeRoute.kind === "consult" @@ -716,14 +726,14 @@ export default function Home() { }, [hydrated]); const onboardingAudience = intakeTarget === "other" ? "other" : "self"; const onboardingSubjectName = onboardingAudience === "other" ? onboardingDraft.name.trim() : profile.name.trim(); - const currentOnboardingMessage = onboardingStep === "who" + const currentOnboardingMessage = shownOnboardingStep === "who" ? whoQuestion(profile.name.trim()) - : onboardingStep === "birth" + : shownOnboardingStep === "birth" ? birthQuestion(onboardingSubjectName, onboardingAudience) - : onboardingStep === "place" + : shownOnboardingStep === "place" ? placeQuestion(onboardingDraft, onboardingAudience, onboardingSubjectName) : presetOnboardingMessage; - const shouldStreamOnboarding = !profileComplete; + const shouldStreamOnboarding = subjectCatalogReady && !profileComplete; const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length; const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null; const starterHomeVisible = profileComplete @@ -737,7 +747,7 @@ export default function Home() { && !activeSession.messagesHydrated && pendingSessionId !== activeSession.id, ); - const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name"; + const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name"; const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id); const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions); const conversationAnchor = useConversationScrollAnchor( @@ -941,10 +951,10 @@ export default function Home() { }, [starterHomeVisible]); useEffect(() => { - if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) { + if (hydrated && accountId && subjectCatalogReady && !profileComplete && shownOnboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) { composerInput.current?.focus({ preventScroll: true }); } - }, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]); + }, [accountId, activeAccountDialog, hydrated, presetMessageFinished, profileComplete, shownOnboardingStep, subjectCatalogReady]); useEffect(() => { setOnboardingFinishedListener((subjectId) => { @@ -1109,7 +1119,7 @@ export default function Home() { function submit(event: FormEvent) { event.preventDefault(); if (!synastryChat && !profileComplete) { - if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName(); + if (shownOnboardingStep === "name" && presetMessageFinished) void saveOnboardingName(); return; } if (synastryClosed || subjectDeleted) return; @@ -1159,7 +1169,7 @@ export default function Home() { visibleSessions, }); - if (!hydrated || (!account && !accountError)) { + if (!hydrated || (!account && !accountError) || (Boolean(account) && !accountError && !subjectCatalogReady)) { const loadingCopy = bootstrapLoadingCopy(bootstrapPhase); return (
@@ -1303,7 +1313,7 @@ export default function Home() { intakeTarget={intakeTarget} onChooseSelf={chooseOnboardingSelf} onChooseOther={chooseOnboardingOther} - onboardingStep={onboardingStep} + onboardingStep={shownOnboardingStep} presetMessageLength={presetMessageLength} currentOnboardingMessage={currentOnboardingMessage} onboardingJustCompleted={onboardingJustCompleted} @@ -1384,12 +1394,12 @@ export default function Home() { {rectificationComposerReopen ? void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null} readStarterSigns(subjectId), () => readStarterSigns(subjectId)); if (!subjectId || subjectId === "self") return ""; const name = catalog?.find((row) => row.id === subjectId)?.profile.name.trim() ?? ""; - const sign = displaySign(signs.ascendantSign); - if (!name || !sign) return ""; - return `那一刻,${name}的上升在${sign}`; + return otherSkySubjectLine(name, signs.ascendantSign); } /** Largest 3:4 box that fits the viewport, in CSS pixels. */ diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index 5a321386..0b74fe57 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -43,7 +43,7 @@ import { redirectToLogin, resolveLookupBootstrap, } from "@/lib/home-cloud-sync"; -import { chartSnapshotForSession, isProfileComplete, missingProfileStep, readProfile } from "@/lib/home-profile"; +import { chartSnapshotForSession, missingProfileStep, onboardingCatalogDecision, readProfile } from "@/lib/home-profile"; import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject"; import { readOnboardingIntake } from "@/lib/onboarding-intake"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; @@ -127,6 +127,8 @@ export type HomeBootstrapDeps = { consultationStatusMissingCount: { current: number }; isDevelopment: boolean; readSearch: () => string; + /** Defaults to the live people list. `null` is "not loaded", not "nobody else". */ + readSubjectCatalog?: () => ReturnType; storage: HomeBootstrapStorage; restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"]; setComposerNotice: (notice: string) => void; @@ -219,6 +221,10 @@ async function resolveReservedConsultation(input: { return { reservedConsultation, storedPending }; } +function subjectCatalogForOnboarding(deps: HomeBootstrapDeps) { + return deps.readSubjectCatalog ? deps.readSubjectCatalog() : peekSubjectCatalog(); +} + export async function runAccountPhase( deps: HomeBootstrapDeps, signal: AbortSignal, @@ -351,9 +357,9 @@ export async function runAccountPhase( const nextProfile = readProfile(nextAccount.profile); // The onboarding shell is no longer in the first-screen bundle (BUG-1130); // someone who will see it gets it before reveal, not a blank frame after. - const onboardingOthers = peekSubjectCatalog() ?? []; const onboardingHint = readOnboardingIntake(nextAccount.user.id); - if (!isProfileComplete(nextProfile, onboardingOthers, onboardingHint)) await io.preloadOnboardingShell(); + const onboardingDecision = onboardingCatalogDecision(nextProfile, subjectCatalogForOnboarding(deps), onboardingHint); + if (onboardingDecision.known && onboardingDecision.step !== null) await io.preloadOnboardingShell(); if (signal.aborted) return { kind: "stop" }; const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; @@ -444,7 +450,14 @@ export async function resolveLanding( deps.commit.setProfile(ready.nextProfile); deps.commit.setProfileDraft(ready.nextProfile); deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : ""); - deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile, peekSubjectCatalog() ?? [], readOnboardingIntake(ready.nextAccount.user.id)) ?? "name"); + const landingDecision = onboardingCatalogDecision( + ready.nextProfile, + subjectCatalogForOnboarding(deps), + readOnboardingIntake(ready.nextAccount.user.id), + ); + // Unknown stays on the name placeholder. It must not commit 「看谁的盘」 + // from an empty stand-in; the page shows the real step once the list arrives. + deps.commit.setOnboardingStep(landingDecision.known ? (landingDecision.step ?? "name") : "name"); deps.commit.setSessions(nextSessions); if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); deps.commit.setActiveSessionId(landingSessionId); diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts index f0e9f058..b5e962c5 100644 --- a/frontend/src/lib/home-profile.ts +++ b/frontend/src/lib/home-profile.ts @@ -401,3 +401,20 @@ export function isProfileComplete( ) { return missingProfileStep(profile, others, hint) === null; } + +/** + * `others === null` means the people list has not arrived. + * That is not an empty list. An empty list means nobody else, so a name-only + * holder is still unfinished. Unknown must not be judged unfinished: an + * account whose only finished chart belongs to someone else would flash + * 「看谁的盘」 and would be refused a warm start. + * `step` is only meaningful when `known` is true. `null` then means finished. + */ +export function onboardingCatalogDecision( + profile: Profile, + others: readonly OnboardingOtherRecord[] | null, + hint: OnboardingIntakeHint | null = null, +): { readonly known: boolean; readonly step: OnboardingStep | null } { + if (others === null) return { known: false, step: null }; + return { known: true, step: missingProfileStep(profile, others, hint) }; +} diff --git a/frontend/src/lib/home-warm-start.ts b/frontend/src/lib/home-warm-start.ts index 8b011dd8..36bc63db 100644 --- a/frontend/src/lib/home-warm-start.ts +++ b/frontend/src/lib/home-warm-start.ts @@ -26,9 +26,8 @@ import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation import { createSession, readStoredDailyStarlanguage } from "@/lib/home-cloud-sync"; import { chartSnapshotForSession, - isProfileComplete, missingOtherProfileStep, - missingProfileStep, + onboardingCatalogDecision, readProfile, } from "@/lib/home-profile"; import { @@ -160,11 +159,12 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart | const snapshot = readHomeWarmSnapshot(account.user.id); if (!isCompleteHomeWarmSnapshot(snapshot)) return null; const profile = readProfile(account.profile); - const onboardingOthers = input.subjectCatalog ?? []; const onboardingHint = readOnboardingIntake(account.user.id); // Onboarding keeps the cold path: its streamed questions and birth-time - // assessment are not part of the snapshot. - if (!isProfileComplete(profile, onboardingOthers, onboardingHint)) return null; + // assessment are not part of the snapshot. A list that has not arrived is + // not that path — only a known unfinished profile is. + const catalogDecision = onboardingCatalogDecision(profile, input.subjectCatalog, onboardingHint); + if (catalogDecision.known && catalogDecision.step !== null) return null; const catalog = snapshot.modelCatalog; const landing = resolveWarmLanding({ sessions: input.sessions, @@ -182,7 +182,7 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart | modelCatalog: catalog, profile, startGreeting: profile.name.trim() ? createStartGreeting(profile.name) : "", - onboardingStep: missingProfileStep(profile, onboardingOthers, onboardingHint) ?? "name", + onboardingStep: catalogDecision.step ?? "name", entrySummary: snapshot.entrySummary, daily: warmDaily(account.user.id, input.subjectId, subjectProfile(input.subjectId, profile, input.subjectCatalog), input.now), sessionsCursor: input.boot === snapshot.listBoot ? snapshot.sessionsCursor : input.boot.cursor, diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index 061bb5d4..f04a9e27 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -248,7 +248,8 @@ export function SessionListProvider({ children }: { children: ReactNode }) { // no Home is mounted, fill the snapshot in the background (read-only GETs). // Home mounting, sign-out, an account switch or a list reload cancels it. const onboardingCatalog = useSyncExternalStore(subscribeSubjectCatalog, peekSubjectCatalog, () => null); - const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile), onboardingCatalog ?? []) + const prefetchAccountId = settled && !signedOut && !registration && account && onboardingCatalog !== null + && isProfileComplete(readProfile(account.profile), onboardingCatalog) ? account.user.id : null; useEffect(() => { diff --git a/frontend/src/lib/starter-questions.ts b/frontend/src/lib/starter-questions.ts index 90567954..4bde7910 100644 --- a/frontend/src/lib/starter-questions.ts +++ b/frontend/src/lib/starter-questions.ts @@ -15,6 +15,14 @@ const EMPTY_SIGNS: StarterChartSigns = { ascendantSign: null, moonSign: null }; const SIGN_LABELS: Readonly> = SIGN_ZH; +/** Sky reveal subtitle for someone else's chart. Empty until both the name and the ascendant are known. */ +export function otherSkySubjectLine(name: string, ascendantSign: string | null | undefined): string { + const trimmed = name.trim(); + const sign = displaySign(ascendantSign); + if (!trimmed || !sign) return ""; + return `那一刻,${trimmed}的上升在${sign}`; +} + /** English engine names become the short Chinese sign. An already-Chinese label is kept, without 座. */ export function displaySign(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index da19bc2c..8a7c983b 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -243,10 +243,10 @@ test("onboarding form steps hide the dead composer and keep source choices in a const page = homeSurface; const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - // 原值: const onboardingFormActive = !profileComplete && onboardingStep !== "name" - // 新值: const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name" - // 原因: 合盘追问可以跳过引导表单。普通对话仍是资料未完成、且不在称呼步时才收起输入框。 - assert.match(page, /const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name"/); + // 原值: const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name" + // 新值: const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name" + // 原因: 人物目录未到时不收起输入框、也不闪「看谁的盘」。目录已知且资料未完成、又不在称呼步时,仍收起。合盘追问仍跳过引导表单。 + assert.match(page, /const onboardingFormActive = !synastryChat && subjectCatalogReady && !profileComplete && shownOnboardingStep !== "name"/); assert.match(page, /!rectificationSurfaceOpen && !onboardingFormActive &&
{ diff --git a/frontend/tests/home-first-load-20260930.test.tsx b/frontend/tests/home-first-load-20260930.test.tsx index abfdde4b..b9d5543a 100644 --- a/frontend/tests/home-first-load-20260930.test.tsx +++ b/frontend/tests/home-first-load-20260930.test.tsx @@ -413,6 +413,9 @@ test("the home first screen no longer imports the city table by value (T7)", () }); test("an incomplete profile waits for the onboarding shell before reveal; a complete one does not load it (T7)", async () => { + // 原值:人物目录还没到时用空数组,只有名字的账户也会预载引导页。 + // 新值:目录用已知的空列表才预载;目录还没到(null)不预载,也不把步骤写成「看谁的盘」。 + // 原因:目录未到是未知,不是「没有别人」。已知的空列表仍表示没建完档。 installWindow(); const restoreStorage = installStorage({}); for (const complete of [false, true]) { @@ -433,6 +436,7 @@ test("an incomplete profile waits for the onboarding shell before reveal; a comp consultationStatusMissingCount: { current: 0 }, isDevelopment: false, readSearch: () => "", + readSubjectCatalog: () => [], storage: { getItem: (key: string) => storage.get(key) ?? null, removeItem: (key: string) => { storage.delete(key); } }, restoreConsultationRecovery: noop, setComposerNotice: noop, @@ -462,6 +466,41 @@ test("an incomplete profile waits for the onboarding shell before reveal; a comp await run; assert.deepEqual(phases, ["prepare"]); } + let unknownStep = ""; + let unknownPreloads = 0; + const unknownProfile = { ...emptyProfile, name: "Synthetic" }; + const unknownBoot: SessionListBoot = { sessions: [], rawRows: [], cursor: null, account: { ...account, profile: unknownProfile }, signedOut: false }; + const unknownDeps: HomeBootstrapDeps = { + sessionListReady: Promise.resolve(), + sessionListBoot: () => unknownBoot, + uiPreview: { current: false }, uiPreviewMode: { current: null }, + consultationStatusMissingCount: { current: 0 }, + isDevelopment: false, + readSearch: () => "", + readSubjectCatalog: () => null, + storage: { getItem: () => null, removeItem: () => undefined }, + restoreConsultationRecovery: () => undefined, + setComposerNotice: () => undefined, + commit: { + setAccount: () => undefined, setAccountError: () => undefined, setActiveSessionId: () => undefined, + setBirthTimeAssessmentPhase: () => undefined, setBirthTimeJourney: () => undefined, + setBootstrapPhase: () => undefined, setGuidedJourneyPreview: () => undefined, + setHydrated: () => undefined, setModelCatalog: () => undefined, + setOnboardingStep: (step) => { unknownStep = step; }, + setProfile: () => undefined, setProfileDraft: () => undefined, + setSessions: () => undefined, setSessionsCursor: () => undefined, setStartGreeting: () => undefined, + }, + io: { + fetchModelCatalog: async () => previewModelCatalog, + fetchActiveConsultationStatus: async () => null, + startEntrySummaryRead: () => undefined, + preloadOnboardingShell: () => { unknownPreloads += 1; return Promise.resolve(); }, + resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null }), + }, + }; + await runHomeBootstrap(unknownDeps, new AbortController().signal); + assert.equal(unknownPreloads, 0, "an unknown people list does not preload the who-step shell"); + assert.equal(unknownStep, "name", "an unknown people list does not commit 看谁的盘"); restoreStorage(); }); diff --git a/frontend/tests/home-warm-snapshot.test.ts b/frontend/tests/home-warm-snapshot.test.ts index 8a81a905..9d65fc3f 100644 --- a/frontend/tests/home-warm-snapshot.test.ts +++ b/frontend/tests/home-warm-snapshot.test.ts @@ -201,6 +201,9 @@ test("warm start is refused whenever a piece is missing", () => { assert.equal(resolveHomeWarmStart(input({ boot: { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" } })), null, "list error"); assert.equal(resolveHomeWarmStart(input({ previewRequested: true })), null, "development preview"); assert.equal(resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }) })), null, "onboarding profile"); + const unknownCatalog = resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }), subjectCatalog: null })); + assert.ok(unknownCatalog, "a people list that has not arrived is not an unfinished profile"); + assert.equal(unknownCatalog.onboardingStep, "name", "unknown does not commit 看谁的盘"); assert.equal(resolveHomeWarmStart(input({ search: "?c=99999999-9999-4999-8999-999999999999" })), null, "a ?c= not in memory needs the cold lookup"); writeHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })); assert.equal(resolveHomeWarmStart(input()), null, "summary never settled"); diff --git a/frontend/tests/onboarding-intake-20261022.test.ts b/frontend/tests/onboarding-intake-20261022.test.ts index cf59260a..fdf12e93 100644 --- a/frontend/tests/onboarding-intake-20261022.test.ts +++ b/frontend/tests/onboarding-intake-20261022.test.ts @@ -3,9 +3,9 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { readOnboardingIntake } from "../src/lib/onboarding-intake.ts"; -import { missingProfileStep } from "../src/lib/home-profile.ts"; +import { missingProfileStep, onboardingCatalogDecision } from "../src/lib/home-profile.ts"; import { emptyProfile, type Profile } from "../src/lib/home-types.ts"; -import { starterQuestions, type StarterChartSigns } from "../src/lib/starter-questions.ts"; +import { otherSkySubjectLine, starterQuestions, type StarterChartSigns } from "../src/lib/starter-questions.ts"; /** Fictional chart. 1990-01-02 and 杭州 are not a real person's birth data. */ function chart(name: string, ready: boolean): Profile { @@ -49,6 +49,17 @@ test("missingProfileStep: self complete, only someone else complete, neither, an ), "place"); assert.equal(missingProfileStep(selfName, [otherPlace], { target: "other", otherId: "other-1" }), "place"); assert.equal(missingProfileStep({ ...emptyProfile }, []), "name"); + assert.equal(onboardingCatalogDecision(selfName, null).known, false); + assert.equal(onboardingCatalogDecision(selfName, []).known, true); + assert.equal(onboardingCatalogDecision(selfName, []).step, "who"); + assert.equal(onboardingCatalogDecision(selfName, [otherDone]).step, null); +}); + +test("someone else's sky subtitle names them and their ascendant, and stays blank without either", () => { + assert.equal(otherSkySubjectLine("小舟", "Taurus"), "那一刻,小舟的上升在金牛"); + assert.equal(otherSkySubjectLine("小舟", "金牛座"), "那一刻,小舟的上升在金牛"); + assert.equal(otherSkySubjectLine("小舟", null), ""); + assert.equal(otherSkySubjectLine(" ", "Taurus"), ""); }); test("starter questions use the chart signs already on hand, for both people", () => {