{starterLead(audience)}
diff --git a/frontend/src/hooks/use-birth-sky-reveal.ts b/frontend/src/hooks/use-birth-sky-reveal.ts
index 25c235cc..72a5c9e3 100644
--- a/frontend/src/hooks/use-birth-sky-reveal.ts
+++ b/frontend/src/hooks/use-birth-sky-reveal.ts
@@ -23,6 +23,14 @@ export function useBirthSkyReveal() {
cancel.current = startBirthSkyReveal
({
// The overlay chunk loads inside the same budget, so the first frame never waits for code.
load: async (signal) => {
+ // Someone else's sky names their ascendant (「那一刻,{名字}的上升在……」).
+ // Their 今日星语 is not read, so the sign comes from the computed chart;
+ // the line appears when it arrives and is left out if it never does (BUG-1459).
+ if (id !== "self") {
+ void import("@/lib/starter-chart-signs")
+ .then((module) => module.loadStarterSignsFromChart(id))
+ .catch(() => undefined);
+ }
const [sky] = await Promise.all([
loadBirthSkyRevealSky(signal, id),
import("@/components/birth-sky/birth-sky-converge"),
diff --git a/frontend/src/hooks/use-drag-width.ts b/frontend/src/hooks/use-drag-width.ts
new file mode 100644
index 00000000..6c4c8cd8
--- /dev/null
+++ b/frontend/src/hooks/use-drag-width.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
+
+/**
+ * A column edge you can drag or move with the arrow keys (BUG-1456 / BUG-1457).
+ * `edge: "right"` is a column on the left whose right edge moves (the sidebar);
+ * `edge: "left"` is a column on the right whose left edge moves (the chat chart).
+ */
+export type DragWidthEdge = "left" | "right";
+
+export const DRAG_WIDTH_KEY_STEP = 16;
+
+/** Width after the pointer moved `deltaX` pixels from where the drag began. */
+export function dragWidthFromPointer(startWidth: number, deltaX: number, edge: DragWidthEdge): number {
+ return Math.round(edge === "right" ? startWidth + deltaX : startWidth - deltaX);
+}
+
+/** Width after one arrow key, clamped to the range; null for any other key. Keys never collapse. */
+export function dragWidthFromKey(
+ current: number,
+ key: string,
+ range: { min: number; max: number },
+ edge: DragWidthEdge,
+ step = DRAG_WIDTH_KEY_STEP,
+): number | null {
+ const grow = edge === "right" ? "ArrowRight" : "ArrowLeft";
+ const shrink = edge === "right" ? "ArrowLeft" : "ArrowRight";
+ let next: number;
+ if (key === grow) next = current + step;
+ else if (key === shrink) next = current - step;
+ else if (key === "Home") next = range.min;
+ else if (key === "End") next = range.max;
+ else return null;
+ return Math.min(range.max, Math.max(range.min, Math.round(next)));
+}
+
+export function useDragWidth({
+ width,
+ min,
+ max,
+ edge,
+ onWidth,
+ onCollapse,
+}: {
+ /** The width shown now, including a collapsed width below `min`. */
+ width: number;
+ min: number;
+ max: number;
+ edge: DragWidthEdge;
+ onWidth: (width: number) => void;
+ /** Dragging below `min` calls this instead of `onWidth`. */
+ onCollapse: () => void;
+}) {
+ const dragStop = useRef<(() => void) | null>(null);
+
+ useEffect(() => () => dragStop.current?.(), []);
+
+ function onPointerDown(event: ReactPointerEvent) {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ dragStop.current?.();
+ const startX = event.clientX;
+ const startWidth = width;
+ const move = (ev: PointerEvent) => {
+ const next = dragWidthFromPointer(startWidth, ev.clientX - startX, edge);
+ if (next < min) {
+ onCollapse();
+ return;
+ }
+ onWidth(Math.min(max, next));
+ };
+ const stop = () => {
+ dragStop.current = null;
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", stop);
+ window.removeEventListener("pointercancel", stop);
+ };
+ dragStop.current = stop;
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", stop);
+ window.addEventListener("pointercancel", stop);
+ }
+
+ function onKeyDown(event: ReactKeyboardEvent) {
+ const next = dragWidthFromKey(width, event.key, { min, max }, edge);
+ if (next === null) return;
+ event.preventDefault();
+ onWidth(next);
+ }
+
+ return {
+ role: "separator" as const,
+ "aria-orientation": "vertical" as const,
+ "aria-valuemin": min,
+ "aria-valuemax": max,
+ "aria-valuenow": Math.min(max, Math.max(min, Math.round(width))),
+ onPointerDown,
+ onKeyDown,
+ };
+}
diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts
index d2d2462b..c5faad78 100644
--- a/frontend/src/hooks/use-profile-onboarding.ts
+++ b/frontend/src/hooks/use-profile-onboarding.ts
@@ -31,7 +31,7 @@ import {
readProfile,
selectedBirthPlace,
} from "@/lib/home-profile";
-import { setCurrentSubject, loadSubjectCatalog, invalidateSubjectCatalog } from "@/lib/current-subject";
+import { setCurrentSubject, refreshSubjectCatalog } from "@/lib/current-subject";
import {
clearOnboardingIntake,
notifyOnboardingFinished,
@@ -324,9 +324,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
await saveOtherProfile(otherPlace.draft, { id: otherPlace.id });
subjectId = otherPlace.id;
clearOnboardingIntake(account.user.id);
- invalidateSubjectCatalog();
setCurrentSubject(subjectId);
- await loadSubjectCatalog();
+ await refreshSubjectCatalog();
notifyOnboardingFinished(subjectId);
setStartGreeting(createStartGreeting(profile.name.trim() || otherPlace.draft.name));
} else {
@@ -371,8 +370,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setIntakeChoice("other");
setChosenOtherId(saved.id);
setDraftOverride(draft);
- invalidateSubjectCatalog();
- void loadSubjectCatalog().catch(() => undefined);
+ void refreshSubjectCatalog().catch(() => undefined);
setPresetMessageLength(0);
setOnboardingStep("birth");
} catch (caught) {
diff --git a/frontend/src/lib/current-subject.ts b/frontend/src/lib/current-subject.ts
index 92a27358..d1b03e8b 100644
--- a/frontend/src/lib/current-subject.ts
+++ b/frontend/src/lib/current-subject.ts
@@ -182,6 +182,17 @@ export function resetSubjectCatalogForTests(): void {
clearCatalogRecovery();
}
+/**
+ * Re-read the people list without dropping the one already shown. Once the page
+ * has revealed with a list, a reload must not send it back to the loading screen,
+ * and a failed reload leaves the old list in place (BUG-1455). An account change
+ * still uses `invalidateSubjectCatalog`.
+ */
+export function refreshSubjectCatalog(): Promise {
+ catalogRequest = null;
+ return loadSubjectCatalog(true);
+}
+
export function invalidateSubjectCatalog(): void {
catalogGeneration += 1;
catalogRequest = null;
@@ -270,6 +281,8 @@ export function loadSubjectCatalog(force = false): Promise
return rememberCatalog(account, library, false);
} catch (error) {
if (generation !== catalogGeneration) throw error;
+ // A refresh that fails keeps the full list it already had (BUG-1455).
+ if (catalogReady && catalog && !othersUnavailable && catalogAccountId === account.user.id) throw error;
const self = readProfile(account.profile);
await loadPlacesFor([self]);
const library = upsertSelfChart([], self);
diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx
index 52a50bcd..b47d2cb8 100644
--- a/frontend/src/lib/onboarding-journey.tsx
+++ b/frontend/src/lib/onboarding-journey.tsx
@@ -90,6 +90,9 @@ type HomeActions = {
export type OnboardingJourney = {
readonly showChecklist: boolean;
+ /** True while the account's checklist answer is still being read (BUG-1458). */
+ readonly checklistPending: boolean;
+ readonly userId: string;
readonly items: readonly ChecklistItem[];
readonly label: string;
readonly celebration: boolean;
@@ -573,6 +576,11 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
arrivedComplete,
})
&& (!canDecide || arrivalSettled);
+ const checklistPending = userId !== "" && !dismissed && (
+ remote.status === "loading"
+ || remote.userId !== userId
+ || (canDecide && !arrivalSettled)
+ );
const motionEnabled = onboardingMotionEnabled({
checklistDismissed: Boolean(merged.checklistDismissedAt),
checklistOpen: showChecklist,
@@ -667,6 +675,8 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
const value = useMemo(() => ({
showChecklist,
+ checklistPending,
+ userId,
items,
label: progress.label,
celebration,
@@ -686,6 +696,8 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
motionEnabled,
}), [
showChecklist,
+ checklistPending,
+ userId,
items,
progress.label,
celebration,
diff --git a/frontend/src/lib/starter-chart-signs.ts b/frontend/src/lib/starter-chart-signs.ts
new file mode 100644
index 00000000..beb6c392
--- /dev/null
+++ b/frontend/src/lib/starter-chart-signs.ts
@@ -0,0 +1,49 @@
+import type { ChartViewResponse } from "@/lib/chart-view-contract";
+import { peekChartPage, refreshChartPage } from "@/lib/secondary-page-data";
+import { rememberStarterSigns, type StarterChartSigns } from "@/lib/starter-questions";
+
+/**
+ * Signs for the starter question and the sky line about someone else, read
+ * from the chart the engine already computed (BUG-1459). The product allowed
+ * this one extra read on 2026-10-27; it goes through the chart page's shared
+ * request, so opening 星盘 afterwards reuses it. Loaded on demand, never in the
+ * first screen. Any failure leaves today's wording.
+ *
+ * The ascendant is used only when it cannot change inside the reported birth
+ * time (#112 `lagnaPrecheck.state === "fixed"`); otherwise only the Moon.
+ */
+export function starterSignsFromChartView(view: ChartViewResponse | null | undefined, subjectId: string): StarterChartSigns | null {
+ if (!view || view.status !== "ok") return null;
+ if ((view.subjectId ?? "self") !== subjectId) return null;
+ const moonSign = view.vedic.planets.find((row) => row.name === "Moon")?.sign ?? null;
+ const ascendantSign = view.lagnaPrecheck?.state === "fixed" ? view.vedic.ascendant?.sign ?? null : null;
+ if (!moonSign && !ascendantSign) return null;
+ return { ascendantSign, moonSign };
+}
+
+const attempted = new Set();
+
+/** One read per subject per page load. Resolves to the signs it remembered, or null. */
+export async function loadStarterSignsFromChart(subjectId: string): Promise {
+ const id = subjectId.trim() || "self";
+ const cached = peekChartPage();
+ const fromCache = cached?.kind === "view" ? starterSignsFromChartView(cached.view, id) : null;
+ if (fromCache) {
+ rememberStarterSigns(id, fromCache);
+ return fromCache;
+ }
+ if (attempted.has(id)) return null;
+ attempted.add(id);
+ try {
+ const snapshot = await refreshChartPage();
+ const signs = snapshot.kind === "view" ? starterSignsFromChartView(snapshot.view, id) : null;
+ if (signs) rememberStarterSigns(id, signs);
+ return signs;
+ } catch {
+ return null;
+ }
+}
+
+export function resetStarterChartSignsForTests(): void {
+ attempted.clear();
+}
diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts
index 75291cfc..c0b587a5 100644
--- a/frontend/src/modules/identity/client.ts
+++ b/frontend/src/modules/identity/client.ts
@@ -175,13 +175,7 @@ export type SelfHostedOtpClient = SelfHostedAuthClient;
export type SelfHostedOtpActions = SelfHostedAuthActions;
export const createSelfHostedOtpActions = createSelfHostedAuthActions;
-// better-auth stores the fetch function it is given when this module loads.
-// Reading the current global fetch on each request keeps the browser on the
-// same fetch, and lets a test replace it after the module has already loaded.
const authClient = createAuthClient({
- fetchOptions: {
- customFetchImpl: (input, init) => globalThis.fetch(input, init),
- },
plugins: [emailOTPClient(), twoFactorClient()],
});
diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts
index 68b8472d..6c3c0b69 100644
--- a/frontend/tests/birth-time-guided-review-fixes.test.ts
+++ b/frontend/tests/birth-time-guided-review-fixes.test.ts
@@ -128,6 +128,12 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
assert.doesNotMatch(hookSource, /completeCandidate:/);
});
+// 原值(365a623fc 之前):test("unconfirmed terminal candidates preserve the range without offering direct adoption")
+// 核对 birth-time-candidate-result / birth-time-choice-question / birth-time-rectification /
+// birth-time-legacy-rectification 四个文件的源码(候选未达采用条件的文案、childOwnsError 分工、ErrorToast 只一处)
+// 新值:整条删除
+// 原因:这四个旧校正组件已经没有页面入口,随 #111 一起删除(按「多余入口宁可删除」)。
+// 现行校正在对话里,候选不直接采用由 rectification-agentic 的测试守住;本文件其余条目继续守引导 hook。
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
const pageSource = homeSurface;
diff --git a/frontend/tests/chart-type-picker.test.tsx b/frontend/tests/chart-type-picker.test.tsx
index 3d11c818..ed04b32a 100644
--- a/frontend/tests/chart-type-picker.test.tsx
+++ b/frontend/tests/chart-type-picker.test.tsx
@@ -127,6 +127,11 @@ test("the type sheet closes when a cell is chosen or Escape is pressed", async (
} finally {
await h.close();
}
+ // 原值(365a623fc):删掉了 doesNotMatch(source, /