feat(ui): one loading curtain for every page entry, drawn as a North Indian chart (BUG-1613)
Independent Staging Quality Gate / validate-frontend (push) Failing after 4m52s
Independent Staging Quality Gate / validate-python (push) Successful in 6m55s
Independent Staging Quality Gate / publish (push) Skipped

- New ChartDrawLoader (direction B from the loader canvas): outer square,
  diagonals, inner diamond drawn in turn, the lagna house lights, repeat.
  Still under prefers-reduced-motion.
- PageCurtain moves into the app shell: in the server HTML, full window over
  sidebar and page, held by usePageCurtain. Cold load shows it from the first
  paint; in-app navigation shows it only after 140 ms (pages already in memory
  never flash it), keeps it at least 650 ms, fades in 360 ms; one hold lets go
  after 12 s. The main.app-loading DOM contract is kept for the first-paint
  fallback and stale-client recovery on every page.
- Held by: home (with the natal panel), chart (first entry only), people,
  reports list, opening a report, ephemeris, annual, rectification entry,
  synastry picker. Home's own curtain (boot-curtain.tsx) is removed.
- The in-page waits that remain (dasha layer, people dropdown, birth editor,
  birth-time assessment, report and synastry writing) use the same motion at
  small size. Skeletons keep their motion: switching person, system or layer
  on the chart still shows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
This commit is contained in:
2026-11-05 15:29:37 +08:00
co-authored by Claude Opus 5.5
parent 172a270b69
commit 849343cd21
28 changed files with 472 additions and 131 deletions
+10 -3
View File
@@ -22047,8 +22047,8 @@
## BUG-1613 | 首页冷启动:加载时侧栏比实际窄、揭幕后右侧本命盘还要再等一两秒
- 状态:fixed-pending-verify(代码 + 回归测试;登录态真机未测)
- 首次发现 / 最近更新:2026-11-05(产品负责人截图反馈)/ 2026-11-05
- 状态:fixed-pending-verify(两轮:首页幕布 + 全站统一加载幕布;代码 + 回归测试;登录态真机未测)
- 首次发现 / 最近更新:2026-11-05(产品负责人截图反馈)/ 2026-11-05(第二轮:产品选定加载动画方向 B,要求全站进页面都用同一张幕布)
- 影响面:`frontend/src/components/shell-frame.tsx`、`app/globals.css`、`app/layout.tsx`、`lib/sidebar-state.ts`(侧栏宽度);`app/(app)/page.tsx`、新增 `components/boot-curtain.tsx`、新增 `lib/chat-chart-preload.ts`、`components/chat-chart-frame.tsx`、`lib/home-bootstrap.ts`、`lib/boot-early-reads.ts`(启动幕布与右栏预载)。
- 现象:
1. 「正在载入账户」时左侧栏约 184px,进页后变成 288px(或用户拖过的宽度),整列跳一下。
@@ -22063,7 +22063,14 @@
2. 冷启动加载屏改为全窗口幕布(`BootCurtain`,`position: fixed` 盖住侧栏 / 中间 / 右栏),DOM 合同不变:仍是 `main.app-loading[aria-busy="true"]` 内含 `.app-loading-content`。揭幕时同一幕布再画一次并在 360ms 内淡出(`BootCurtainExit`:不带 `.app-loading`,圆环静止、没有转动的 orbit,符合揭幕后无 spinner;`prefers-reduced-motion` 下立即消失),热返回首页不显示。
3. 右栏进入揭幕门槛:右栏会出现时(宽度 ≥768、没收起、资料完整),prepare 阶段调 `preloadChatChartPanel()`,代码与 `ensureChartPage()` 并行,都有结果(失败也算)才揭幕;仍受 4 秒 prepare 预算约束,超时照旧揭幕、右栏随后补上。`ChatChartFrame` 首帧就从 `peekChatChartSplit()` 拿到已解析的模块。首页 HTML 早读同一轮加发记住的人的 `/api/chart-view`(仅宽屏且右栏没收起)。
- 验证:新增 `frontend/tests/boot-curtain-20261105.test.tsx` 5 条(揭幕门槛、右栏适用条件、预载与淡出合同、侧栏宽度脚本四种存储状态与 pending CSS、首页早读含盘面且手机 / 收起时不发);按例外条款改写 `chat-chart-panel-state`、`chart-board-20261027`、`home-bootstrap-reveal`、`chat-navigation-a11y-contract` 的源码断言(附原值 / 新值 / 原因)。全套 `CI=1 npm test` 5709 条,修正后剩余失败均为环境原因(PostgreSQL / Docker / swisseph / PyYAML);`tsc --noEmit` 0 错;改动文件 eslint 0 error;`next build --webpack` 通过,`/` 仍 `○ Static`。生产构建 + 无头 Chrome(CDP 伪造会话 cookie、挂起 `/api/*`):拦掉 bundle 时 `/` 侧栏槽 288px、`data-shell-resize=pending`,幕布 1418×900 全窗口、z-index 70;bundle 跑完后仍 288px(`live`);`/chart` 预置 `sidebar_width=330` 时 bundle 前后都是 330px。登录态揭幕与右栏同帧完整需在 staging 真账号确认。
- 防复发:首屏要出现的栏(侧栏、右栏)必须在揭幕前就绪或被幕布盖住,不得揭幕后再串行加载;视口未知时不得让布局按内容取宽。
- 第二轮(同日,产品:「原来的 logo 转圈不好看」,在 6 个原型里选 B「北印度盘描线」;「进报告、进档案也要加载动画,全项目都用这一个遮罩」;「星盘切换的时候需要有骨架屏,别把骨架屏都删了」):
1. 新增 `components/chart-draw-loader.tsx`:外框 → 两条对角线 → 内菱形依次描出,上升宫亮一下再淡出,3.6s 一轮;`prefers-reduced-motion` 下静止显示画完的盘。
2. 幕布从首页挪到 app 外壳:`components/page-curtain.tsx` 挂在 `app/(app)/layout.tsx`,在服务端 HTML 里就有,整窗盖住侧栏与页面;页面用 `hooks/use-page-curtain.ts` 持有它(`lib/page-curtain-store.ts`)。冷启动从首帧就显示;站内导航等待超过 140ms 才显示(内存里已有数据的页面不闪),显示后至少停 650ms 再 360ms 淡出;单次持有最长 12 秒自动放手。DOM 合同不变(`main.app-loading[aria-busy="true"]` + `.app-loading-content`),首帧兜底脚本与僵死恢复对所有页面都生效。首页不再自己画加载屏,删去 `components/boot-curtain.tsx`。
3. 持有幕布的页面入口:首页(含右栏预载)、星盘(只在第一次进入,等首屏自动请求的图层,最多 2.5 秒)、星盘档案 / 我的报告 / 星历 / 年运(`SecondaryPageShell` 的 `waiting`,年运另等概览或已开报告)、打开某份报告(`ReportOpening`,路由 `loading.tsx` 与首读同一份)、生时校正入口、婚恋合盘选择页。
4. 其余等待改用同一个动画的小号版:`OrbitWaiting`(大运层、人物下拉、出生资料编辑)、`AppLoadingIndicator`(生时评估遮罩)、报告生成中、合盘写报告中;原来的 logo 圆环、合盘轨道、报告 32px 转圈下线。
5. 骨架屏保留原样(含动画):星盘换人、切印度盘 / 西洋盘、开分盘与图层时仍显示骨架屏,不弹幕布。按钮里 12–14px 的小转圈与 AI 回答过程指示保持不变。
6. 验证:新增 `frontend/tests/page-curtain-20261105.test.tsx` 7 条;`home-warm-return-lifecycle` 的测试外壳按 layout 挂上 `PageCurtain`,站内冷启动的首帧断言由「看到加载圈」改为「已持有幕布」(附原值 / 新值 / 原因);`surface-feedback-20260929`、`report-polling-contract`、`personal-report-progress`、`chat-navigation-a11y-contract`、`home-bootstrap-reveal`、`boot-curtain-20261105` 按例外条款改写源码断言。相关 27 个文件 306 条全绿;全套 `CI=1 npm test` 5716 条,剩余失败均为环境原因(PostgreSQL / Docker / swisseph / PyYAML);`tsc --noEmit` 0 错;改动文件 eslint 0 error;`next build --webpack` 通过。生产构建 + 无头 Chrome(伪造会话 cookie、挂起 `/api/*`):`/`、`/people`、`/reports` 幕布 1418×900 整窗、z-index 70,标题按路由;1.3s 时外框描到一半、2.9s 时整盘画完且上升宫亮起。构建中发现 CSS 压缩把无名 `animation:` 简写压成 `animation: none`,已改用分写属性。
- 防复发:首屏要出现的栏(侧栏、右栏)必须在揭幕前就绪或被幕布盖住,不得揭幕后再串行加载;视口未知时不得让布局按内容取宽。新页面入口等首批数据一律用 `usePageCurtain`,不得自画转圈;页内切换用骨架屏。
- 关联记录:BUG-1526(同一加载阶段的侧栏跳动)、BUG-1127(首页 HTML 早读)、BUG-1448(右栏独立于聊天列挂载)。
- 复发自:无
- 修复版本:分支 `feat/global-boot-loader`。
+3
View File
@@ -7,6 +7,7 @@ import { usePathname, useRouter } from "next/navigation";
import { AccountDeletionGate } from "@/components/account-deletion-gate";
import { AppSidebar } from "@/components/app-sidebar";
import { LegalConsentGate } from "@/components/legal/legal-consent-gate";
import { PageCurtain } from "@/components/page-curtain";
import { PageSwitch } from "@/components/shell-motion";
import { ShellFrame } from "@/components/shell-frame";
import { SessionDeleteDialog } from "@/components/session-delete-dialog";
@@ -75,6 +76,8 @@ function AppShell({ children }: { children: ReactNode }) {
</PageSwitch>
</SidebarInset>
</ShellFrame>
{/* One loading screen for every page entry, over sidebar and page (2026-11-05). */}
<PageCurtain />
<SessionDeleteDialog
session={sessionActions.pendingSessionDeletion}
onCancel={sessionActions.cancelDeletion}
+8 -8
View File
@@ -14,7 +14,6 @@ import { ProfilePanel } from "@/components/profile-panel";
import { AccountDeletionSection } from "@/components/account-deletion-section";
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BootCurtain, BootCurtainExit } from "@/components/boot-curtain";
import { OnboardingChecklistSlot } from "@/components/onboarding-checklist-slot";
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
import { HomeOnboardingShellSlot } from "@/components/home-onboarding-shell-slot";
@@ -31,6 +30,7 @@ import type { ChatTranscriptActions } from "@/components/chat-transcript";
import { ChatStreamingTranscript, useStreamingReplyHasText } from "@/components/chat-streaming-transcript";
import { ChatProfilePicker } from "@/components/chat-profile-picker";
import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame";
import { usePageCurtain } from "@/hooks/use-page-curtain";
import { readStoredChatChartOpen } from "@/lib/chat-chart-panel-state";
import { preloadChatChartPanel } from "@/lib/chat-chart-preload";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
@@ -293,8 +293,6 @@ export default function Home() {
const [hydrated, setHydrated] = useState(() => warmStart() !== null);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>(() => (warmStart() ? "prepare" : "account"));
const prepareStartedAt = useRef<number | null>(null);
// A cold boot shows the curtain, and the curtain fades off the finished page; a warm return shows neither.
const [coldBoot] = useState(() => warmStart() === null);
const [chartPanelSettled, setChartPanelSettled] = useState(false);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
@@ -1154,10 +1152,13 @@ export default function Home() {
visibleSessions,
});
if (!hydrated || (!account && !accountError) || (Boolean(account) && !accountError && !subjectCatalogReady)) {
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
return <BootCurtain title={loadingCopy.title} detail={loadingCopy.detail} />;
}
const booting = !hydrated || (!account && !accountError) || (Boolean(account) && !accountError && !subjectCatalogReady);
// The app layout's page curtain covers the whole window until the home is
// complete (its natal panel included) and fades off it; a warm return never
// holds it. Under it the home renders nothing.
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
usePageCurtain(booting, loadingCopy);
if (booting) return null;
// Bootstrap failures stay fatal even if the provider finishes its own read
// after the bootstrap controller was aborted; never reveal without a catalog.
@@ -1225,7 +1226,6 @@ export default function Home() {
return (
<ChatChartFrame>
{coldBoot ? <BootCurtainExit {...bootstrapLoadingCopy("prepare")} /> : null}
<OnboardingHomeBridge
inputRef={composerInput}
streaming={consultationPhase === "streaming" || consultationPhase === "recovering"}
+25 -3
View File
@@ -839,15 +839,35 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
}
.app-loading { width: 100%; height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--color-canvas); }
/* Home cold boot: one curtain over sidebar, chat and natal panel; it fades off the finished page. */
/* The page curtain (2026-11-05): one full-window loading screen for every page entry; it fades off the finished page. */
.boot-curtain { position: fixed; inset: 0; z-index: 70; width: auto; height: auto; display: grid; place-items: center; padding: 24px; background: var(--color-canvas); }
.boot-curtain.is-entering { animation: boot-curtain-enter 180ms ease-out both; }
.boot-curtain.is-leaving { pointer-events: none; animation: boot-curtain-leave 360ms cubic-bezier(.4, 0, .2, 1) forwards; }
.boot-curtain-ring { position: absolute; inset: 0; border: 1px solid var(--color-canvas-strong); border-radius: 50%; }
.boot-curtain.is-leaving .app-loading-content { animation: boot-curtain-content-leave 360ms cubic-bezier(.4, 0, .2, 1) forwards; }
.boot-curtain .app-loading-chart { margin-bottom: 6px; }
@keyframes boot-curtain-enter { from { opacity: 0; } to { opacity: 1; } }
@keyframes boot-curtain-leave { 0%, 15% { opacity: 1; } 100% { opacity: 0; } }
@keyframes boot-curtain-content-leave { to { transform: translateY(-6px) scale(.98); opacity: 0; } }
/* The one loading motion: a North Indian chart drawn line by line (ChartDrawLoader). */
.chart-draw-loader { display: block; flex: none; overflow: visible; }
.chart-draw-lagna { fill: var(--color-action-strong); opacity: 0; animation: chart-draw-lagna 3.6s ease-in-out infinite; }
.chart-draw-line { fill: none; stroke: var(--color-ink); stroke-width: 1.2; stroke-linejoin: round; stroke-dasharray: 100; stroke-dashoffset: 100; animation-duration: 3.6s; animation-timing-function: cubic-bezier(.6, 0, .3, 1); animation-iteration-count: infinite; }
/* Longhands on purpose: the CSS minifier turns a nameless `animation:` shorthand into `animation: none`. */
.chart-draw-diagonal-a, .chart-draw-diagonal-b { stroke-width: 1; opacity: .75; }
.chart-draw-diamond { stroke: var(--color-action-strong); }
.chart-draw-square { animation-name: chart-draw-square; }
.chart-draw-diagonal-a { animation-name: chart-draw-diagonal-a; }
.chart-draw-diagonal-b { animation-name: chart-draw-diagonal-b; }
.chart-draw-diamond { animation-name: chart-draw-diamond; }
@keyframes chart-draw-square { 0% { stroke-dashoffset: 100; opacity: 1; } 24% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 0; } }
@keyframes chart-draw-diagonal-a { 0%, 18% { stroke-dashoffset: 100; opacity: .75; } 38% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: .75; } 100% { stroke-dashoffset: 0; opacity: 0; } }
@keyframes chart-draw-diagonal-b { 0%, 28% { stroke-dashoffset: 100; opacity: .75; } 48% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: .75; } 100% { stroke-dashoffset: 0; opacity: 0; } }
@keyframes chart-draw-diamond { 0%, 44% { stroke-dashoffset: 100; opacity: 1; } 68% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 0; } }
@keyframes chart-draw-lagna { 0%, 64% { opacity: 0; } 76%, 86% { opacity: .32; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.boot-curtain.is-leaving, .boot-curtain.is-leaving .app-loading-content { animation-duration: 1ms; }
.boot-curtain.is-entering, .boot-curtain.is-leaving, .boot-curtain.is-leaving .app-loading-content { animation-duration: 1ms; }
.chart-draw-line, .chart-draw-lagna { animation: none; stroke-dashoffset: 0; }
.chart-draw-lagna { opacity: .32; }
}
/* Sans on purpose (BUG-1129): this screen paints before the bundle runs; a serif title would fetch 3-4 font slices that compete with the critical JS. */
.app-loading-content strong { margin-top: 10px; color: var(--color-ink); font-family: var(--font-body); font-size: var(--type-title-lg); font-weight: 500; letter-spacing: -.3px; }
@@ -4960,6 +4980,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
}
.synastry-wait p { margin: 0; }
.synastry-wait .sub { max-width: 36rem; color: var(--color-ink-tertiary); font-size: 14px; line-height: 1.6; }
/* The app's one waiting motion since 2026-11-05 (BUG-1613); the orbit rules below are unused. */
.synastry-wait-chart { flex: none; }
.synastry-orbit { position: relative; width: 56px; height: 56px; min-height: 56px; flex: none; }
.synastry-orbit i { position: absolute; inset: 0; border: 1.5px solid var(--color-border-strong); border-radius: 50%; }
.synastry-orbit b {
@@ -451,7 +451,9 @@ export function AnnualReportPage({ initialReportId = null }: Readonly<{ initialR
const failed = report?.status === "failed";
return (
<SecondaryPageShell title="年运" waiting={!subjectReady ? "正在打开年运…" : null}>
// Page entry (2026-11-05): the page curtain stays up until the overview (or an
// opened report) is in, so the page never appears as an empty skeleton.
<SecondaryPageShell title="年运" waiting={!subjectReady || (!overview && !overviewError) || (reportId !== null && report === null) ? "正在打开年运…" : null}>
<div className={`annual-page${showVeil ? " is-veiled" : ""}`}>
{overviewError ? <p className="annual-error" role="alert">{overviewError}</p> : null}
{/* A failed run keeps what it already wrote (BUG-1611): the snapshot is
@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
type AppLoadingIndicatorProps = Readonly<{
title: string;
detail: ReactNode;
@@ -9,10 +11,8 @@ type AppLoadingIndicatorProps = Readonly<{
export function AppLoadingIndicator({ title, detail, className = "" }: AppLoadingIndicatorProps) {
return (
<div className={`app-loading-content${className ? ` ${className}` : ""}`}>
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
{/* The app's one loading motion (2026-11-05); the orbit ring is retired. */}
<ChartDrawLoader className="app-loading-chart" size={72} />
<strong>{title}</strong>
<span>{detail}</span>
</div>
-59
View File
@@ -1,59 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
/** Longest the leaving curtain stays, if `animationend` never fires. */
export const BOOT_CURTAIN_EXIT_MS = 420;
/**
* The home cold boot's loading screen, over the whole window: sidebar, chat
* column and natal panel. It is in the server HTML, so nothing underneath is
* seen while the bundle downloads (the content-sized sidebar) or while the
* reads run (a chat list up with the chart still missing). The DOM contract
* stale-client recovery and the first-paint fallback read is unchanged:
* `main.app-loading[aria-busy="true"]` with `.app-loading-content` inside.
*/
export function BootCurtain({ title, detail }: Readonly<{ title: string; detail: string }>) {
return (
<main className="app-loading boot-curtain" aria-busy="true" aria-live="polite">
<AppLoadingIndicator title={title} detail={detail} />
</main>
);
}
/**
* The same curtain (without the spinner), drawn once more over the revealed page and faded out, so
* the finished page comes up underneath instead of replacing the loader in one
* cut. Decorative only (`aria-hidden`, no pointer events); it removes itself.
* Not `.app-loading`: the first-paint fallback must never find it.
*/
export function BootCurtainExit({ title, detail }: Readonly<{ title: string; detail: string }>) {
const [gone, setGone] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setGone(true), BOOT_CURTAIN_EXIT_MS);
return () => window.clearTimeout(timer);
}, []);
// In the first revealed render, not a frame later: a frame without it would
// show the page once before the fade. `position: fixed` covers the window
// from inside the page, as the loading curtain does (no transformed ancestor
// on a cold boot: PageSwitch animates only route changes).
if (gone) return null;
return (
<div className="boot-curtain is-leaving" aria-hidden="true" onAnimationEnd={() => setGone(true)}>
{/* No spinning orbit once the page is up (no spinner after reveal): the ring
stands still and the curtain fades. Same box as AppLoadingIndicator. */}
<div className="app-loading-content">
<div className="app-loading-symbol">
<span className="boot-curtain-ring" />
<span className="app-loading-mark" />
</div>
<strong>{title}</strong>
<span>{detail}</span>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
/**
* The app's one loading motion (2026-11-05, product picked direction B of the
* loader canvas): a North Indian chart drawn line by line — outer square, the
* two diagonals, the inner diamond — then the lagna house lights, and it fades
* and starts again. Used full size on the page curtain and small for the few
* in-page waits; every other spinner, orbit and skeleton shimmer was retired.
* Under `prefers-reduced-motion` the finished chart stands still.
*/
export function ChartDrawLoader({ size = 84, className = "" }: Readonly<{ size?: number; className?: string }>) {
return (
<svg
className={`chart-draw-loader${className ? ` ${className}` : ""}`}
width={size}
height={size}
viewBox="0 0 80 80"
aria-hidden="true"
focusable="false"
>
<path className="chart-draw-lagna" d="M40 4 L58 22 L40 40 L22 22 Z" />
<path className="chart-draw-line chart-draw-square" pathLength={100} d="M4 4 H76 V76 H4 Z" />
<path className="chart-draw-line chart-draw-diagonal-a" pathLength={100} d="M4 4 L76 76" />
<path className="chart-draw-line chart-draw-diagonal-b" pathLength={100} d="M76 4 L4 76" />
<path className="chart-draw-line chart-draw-diamond" pathLength={100} d="M40 4 L76 40 L40 76 L4 40 Z" />
</svg>
);
}
@@ -26,6 +26,7 @@ import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
import { useChartBoard } from "@/hooks/use-chart-board";
import { useChartPage } from "@/hooks/use-chart-page";
import { usePageCurtain } from "@/hooks/use-page-curtain";
import { useTransitAt } from "@/hooks/use-transit-at";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import {
@@ -338,6 +339,11 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, onAsk
);
}
/** Longest the page curtain waits for the first screen's own layers after the chart is in. */
export const CHART_ENTRY_LAYER_BUDGET_MS = 2500;
/** How long nothing may be pending before the first screen counts as complete. */
export const CHART_ENTRY_QUIET_MS = 120;
export function ChartPageView({
view,
pendingLayers,
@@ -370,6 +376,27 @@ export function ChartPageView({
}) {
const [system, setSystem] = useState<ChartSystem>(initialTab === "western" ? "western" : "vedic");
const journey = useOnboardingJourneyOptional();
// Page entry (2026-11-05): the page curtain stays up until the first chart and the
// layers the first screen asks for by itself (today's sky) are in, so the
// page appears complete; at most CHART_ENTRY_LAYER_BUDGET_MS for the layers.
// Only the first entry: switching person, system or layer later keeps the
// in-page skeletons (product, 2026-11-05: 「切换的时候需要有骨架屏」).
const [entryLayersDone, setEntryLayersDone] = useState(false);
const entryLayersSettled = entryLayersDone || view?.status !== "ok";
const entryPendingCount = pendingLayers?.size ?? 0;
// Settled once nothing has been pending for a moment: the first screen's
// layer requests go out in effects after the chart lands, not with it.
useEffect(() => {
if (view == null || entryLayersDone || (view.status === "ok" && entryPendingCount > 0)) return;
const timer = window.setTimeout(() => setEntryLayersDone(true), CHART_ENTRY_QUIET_MS);
return () => window.clearTimeout(timer);
}, [entryLayersDone, entryPendingCount, view]);
useEffect(() => {
if (view?.status !== "ok" || entryLayersDone) return;
const timer = window.setTimeout(() => setEntryLayersDone(true), CHART_ENTRY_LAYER_BUDGET_MS);
return () => window.clearTimeout(timer);
}, [entryLayersDone, view?.status]);
usePageCurtain(!entryLayersDone && (view == null || !entryLayersSettled));
const setJourneyTab = journey?.setChartTab;
const markChartVisited = journey?.markChartVisited;
const [editingBirth, setEditingBirth] = useState(false);
+6 -6
View File
@@ -1,16 +1,16 @@
import { ChartDrawLoader } from "@/components/chart-draw-loader";
/**
* The one short-wait animation for secondary pages (2026-09-30, product: while
* a chart or a day's ephemeris loads, show motion instead of 「……还没拿到。」).
* It is the home loading screen's orbit ring at a smaller size, so the app has
* one waiting motif. The sentence stays for screen readers only.
* Since 2026-11-05 it is the page curtain's chart-drawing motion at a small
* size, for in-page waits only (a page entry holds the curtain instead), so
* the app has one waiting motif. The sentence stays for screen readers only.
*/
export function OrbitWaiting({ label, className = "" }: Readonly<{ label: string; className?: string }>) {
return (
<div className={`orbit-waiting${className ? ` ${className}` : ""}`} role="status">
<span className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</span>
<ChartDrawLoader size={40} />
<span className="sr-only">{label}</span>
</div>
);
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
import {
PAGE_CURTAIN_EXIT_MS,
PAGE_CURTAIN_SHOW_DELAY_MS,
pageCurtainLeaveDelayMs,
routeCurtainCopy,
type PageCurtainMode,
} from "@/lib/page-curtain";
import { latestPageCurtainCopy, usePageCurtainStore } from "@/lib/page-curtain-store";
/**
* The one loading screen of the app (2026-11-05): a full-window curtain over
* sidebar, page and natal panel, with the chart-drawing motion. It is in the
* server HTML, so a cold load shows it from the first paint; a page entry that
* waits for its first data holds it (`usePageCurtain`), and when the last hold
* goes it fades off a page that is already complete. Nothing else on a page
* animates while it loads.
*
* DOM contract kept from the home loading screen: while up it is
* `main.app-loading[aria-busy="true"]` with `.app-loading-content` inside, which
* the first-paint fallback and stale-client recovery look for. While leaving it
* is the same element without `.app-loading`, so the motion does not restart.
*/
export function PageCurtain() {
const pathname = usePathname() ?? "/";
const holds = usePageCurtainStore((state) => state.holds);
const held = holds.size > 0;
const [mode, setMode] = useState<PageCurtainMode>("boot");
const [entering, setEntering] = useState(false);
const shownAt = useRef<number | null>(null);
const [lastCopy, setLastCopy] = useState(() => routeCurtainCopy(pathname));
const named = latestPageCurtainCopy(holds);
const route = routeCurtainCopy(pathname);
const copy = held
? { title: named?.title || route.title, detail: named?.detail ?? route.detail }
: lastCopy;
if (held && (copy.title !== lastCopy.title || copy.detail !== lastCopy.detail)) setLastCopy(copy);
useEffect(() => {
let timer: number | undefined;
let frame: number | undefined;
const show = (fromHidden: boolean) => {
shownAt.current = Date.now();
setEntering(fromHidden);
setMode("shown");
};
if (held) {
if (mode === "hidden") timer = window.setTimeout(() => show(true), PAGE_CURTAIN_SHOW_DELAY_MS);
else if (mode === "leaving") timer = window.setTimeout(() => show(false), 0);
} else if (mode === "boot") {
// Two frames: a page whose first effect starts its read holds by then.
frame = window.requestAnimationFrame(() => {
frame = window.requestAnimationFrame(() => setMode("leaving"));
});
} else if (mode === "shown") {
timer = window.setTimeout(() => setMode("leaving"), pageCurtainLeaveDelayMs(mode, shownAt.current, Date.now()));
} else if (mode === "leaving") {
timer = window.setTimeout(() => {
shownAt.current = null;
setMode("hidden");
}, PAGE_CURTAIN_EXIT_MS);
}
return () => {
if (timer !== undefined) window.clearTimeout(timer);
if (frame !== undefined) window.cancelAnimationFrame(frame);
};
}, [held, mode]);
if (mode === "hidden") return null;
const leaving = mode === "leaving";
const className = leaving
? "boot-curtain is-leaving"
: `app-loading boot-curtain${entering ? " is-entering" : ""}`;
return (
<main className={className} aria-busy={!leaving} aria-live="polite" aria-hidden={leaving || undefined}>
<div className="app-loading-content">
<ChartDrawLoader className="app-loading-chart" />
<strong>{copy.title}</strong>
{copy.detail ? <span>{copy.detail}</span> : null}
</div>
</main>
);
}
@@ -17,7 +17,7 @@ import Link from "next/link";
import { Clock3, TriangleAlert } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
import { SecondaryHeader } from "@/components/secondary-header";
import { SynastryReportView } from "@/components/synastry/synastry-report-view";
@@ -476,7 +476,8 @@ export function PersonalReportPage({
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
{/* The app's one waiting motion (2026-11-05). Writing shows its own section progress instead. */}
{writing ? null : <ChartDrawLoader size={48} />}
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
{progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY}
</p>
@@ -1,23 +1,24 @@
"use client";
import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
import { usePageCurtain } from "@/hooks/use-page-curtain";
export const REPORT_OPENING_COPY = "正在加载报告…";
/**
* The one waiting screen for opening a report. The route's `loading.tsx` and the
* reader's own first fetch both render exactly this, so the hand-over between
* them is invisible (2026-09-30: the route fallback used to be a bare spinner
* stuck at the top without the header, then a second, centred one replaced it).
* them is invisible (2026-09-30). Since 2026-11-05 it holds the full-window
* page curtain (「正在打开报告」) instead of drawing its own spinner; the header
* stays under it so nothing moves when the curtain fades.
*/
export function ReportOpening() {
usePageCurtain(true, { title: "正在打开报告", detail: "读取报告内容" });
return (
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">{REPORT_OPENING_COPY}</p>
<p className="sr-only" role="status">{REPORT_OPENING_COPY}</p>
</div>
</>
);
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ComponentProps, type ReactNode }
import { AppLink } from "@/components/app-link";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { usePageCurtain } from "@/hooks/use-page-curtain";
import { Button, buttonVariants } from "@/components/ui/button";
import { useSessionList } from "@/hooks/use-session-list";
import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject";
@@ -198,6 +199,9 @@ export function RectificationEntryPage() {
navigateInApp(rectificationStartHref(state, subjectId));
}
// Page entry: the page curtain covers the wait for the status card (2026-11-05);
// the reserved card below only shows on a repeat read, never with motion.
usePageCurtain(pending);
return (
<SecondaryPageShell title={COPY.title} besideTitle={beside}>
<div className="rectification-page">
@@ -3,8 +3,8 @@
import type { ReactNode } from "react";
import { CurrentSubjectSwitcher } from "@/components/current-subject-switcher";
import { OrbitWaiting } from "@/components/orbit-waiting";
import { SecondaryHeader } from "@/components/secondary-header";
import { usePageCurtain } from "@/hooks/use-page-curtain";
export type SecondaryPageShellProps = {
readonly title: string;
@@ -25,8 +25,11 @@ export type SecondaryPageShellProps = {
*
* Header and body stay siblings of `.chat-panel` so the existing 46px + 1fr
* grid still owns height. The body cell (`.secondary-page`) always fills the
* remaining viewport; waiting copy is centered in that cell, so swapping in
* the tall content cannot push the shell.
* remaining viewport, so swapping in the tall content cannot push the shell.
*
* `waiting` (the page's first data is not in yet) holds the full-window page
* curtain (2026-11-05); the body stays empty under it, and
* the page appears complete when the curtain fades. No in-page spinner.
*/
export function SecondaryPageShell({
title,
@@ -36,6 +39,9 @@ export function SecondaryPageShell({
waiting,
children,
}: SecondaryPageShellProps) {
// The curtain says what is opening by route (「正在打开星盘档案」…); the
// page's own waiting sentence was written for an in-page line and reads as a failure there.
usePageCurtain(Boolean(waiting));
return (
<>
<SecondaryHeader
@@ -46,7 +52,7 @@ export function SecondaryPageShell({
actions={actions}
/>
<div className="secondary-page">
{waiting ? <OrbitWaiting className="secondary-page-waiting" label={waiting} /> : children}
{waiting ? null : children}
</div>
</>
);
@@ -30,6 +30,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { usePageCurtain } from "@/hooks/use-page-curtain";
import {
Select,
SelectContent,
@@ -97,6 +98,8 @@ export function SynastryPicker() {
}, [account, catalog]);
const archiveUnavailable = Boolean(account) && catalog !== null && subjectCatalogOthersUnavailable();
const balance = typeof account?.credits === "number" ? account.credits : null;
// Page entry: the page curtain covers the wait for the account and people list (2026-11-05).
usePageCurtain(access === "open" && (!account || catalog === null));
// `?with=` preselects the second person once the list has them.
const [wanted] = useState(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location?.search ?? "").get("with") || "");
const [left, setLeft] = useState("self");
@@ -2,6 +2,8 @@
import { useSyncExternalStore } from "react";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
const STEPS = ["算两张完整命盘", "整理两人之间的证据", "写报告", "检查报告"] as const;
const NO_PAIR = null;
@@ -72,12 +74,7 @@ export function SynastryWait(props: {
);
}
/** The app's one waiting motion (2026-11-05), in place of the synastry orbit. */
function Orbit() {
return (
<div className="synastry-orbit" aria-hidden="true">
<i />
<b />
<b />
</div>
);
return <ChartDrawLoader className="synastry-wait-chart" size={56} />;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useId, useLayoutEffect } from "react";
import { PAGE_CURTAIN_MAX_HOLD_MS } from "@/lib/page-curtain";
import { usePageCurtainStore } from "@/lib/page-curtain-store";
/**
* Hold the full-window page curtain while `loading` (a page entry waiting for
* its first data). Layout effect: the hold is in before the first paint, so a
* cold load never shows the page under a lifted curtain for a frame. A hold
* lets go by itself after PAGE_CURTAIN_MAX_HOLD_MS: a read that never answers
* leaves the page's own state on screen, never an endless curtain.
*/
export function usePageCurtain(loading: boolean, copy?: Readonly<{ title?: string; detail?: string }>): void {
const key = useId();
const hold = usePageCurtainStore((state) => state.hold);
const release = usePageCurtainStore((state) => state.release);
const title = copy?.title;
const detail = copy?.detail;
useLayoutEffect(() => {
if (!loading) return;
hold(key, { title, detail });
const cap = window.setTimeout(() => release(key), PAGE_CURTAIN_MAX_HOLD_MS);
return () => {
window.clearTimeout(cap);
release(key);
};
}, [detail, hold, key, loading, release, title]);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { create } from "zustand";
/**
* Who is holding the page curtain up (2026-11-05). Every page entry that
* waits for its first data holds it through `usePageCurtain`; the curtain in
* the app layout shows while any hold is active. The newest hold's copy is
* what the curtain says.
*/
export type PageCurtainCopy = Readonly<{ title?: string; detail?: string }>;
type PageCurtainState = {
holds: ReadonlyMap<string, PageCurtainCopy>;
hold: (key: string, copy: PageCurtainCopy) => void;
release: (key: string) => void;
};
export const usePageCurtainStore = create<PageCurtainState>((set) => ({
holds: new Map(),
hold: (key, copy) => set((state) => {
const holds = new Map(state.holds);
holds.delete(key);
holds.set(key, copy);
return { holds };
}),
release: (key) => set((state) => {
if (!state.holds.has(key)) return state;
const holds = new Map(state.holds);
holds.delete(key);
return { holds };
}),
}));
export function latestPageCurtainCopy(holds: ReadonlyMap<string, PageCurtainCopy>): PageCurtainCopy | null {
let latest: PageCurtainCopy | null = null;
for (const copy of holds.values()) latest = copy;
return latest;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Page curtain timing and copy (2026-11-05). Pure, so the rules are testable.
*
* - Cold load: the curtain is in the server HTML and covers the whole window
* until the page's first data is in; then it fades.
* - In-app navigation: a page that waits holds it; it appears only if the wait
* outlasts `PAGE_CURTAIN_SHOW_DELAY_MS` (a page already in memory never
* flashes it), and once up it stays at least `PAGE_CURTAIN_MIN_VISIBLE_MS`.
*/
export const PAGE_CURTAIN_SHOW_DELAY_MS = 140;
export const PAGE_CURTAIN_MIN_VISIBLE_MS = 650;
export const PAGE_CURTAIN_EXIT_MS = 360;
/** Longest one page entry may hold the curtain. */
export const PAGE_CURTAIN_MAX_HOLD_MS = 12_000;
export type PageCurtainMode = "boot" | "shown" | "leaving" | "hidden";
/** What the curtain says when no hold named anything (and in the server HTML). */
export function routeCurtainCopy(pathname: string): Readonly<{ title: string; detail: string }> {
if (pathname === "/") return { title: "正在载入账户", detail: "同步个人资料与对话记录" };
if (pathname.startsWith("/chart")) return { title: "正在打开星盘", detail: "排盘与读取大运" };
if (pathname.startsWith("/people")) return { title: "正在打开星盘档案", detail: "读取人物列表" };
if (pathname.startsWith("/reports/annual")) return { title: "正在打开年运", detail: "读取今年的运势" };
if (/^\/reports\/[^/]+/.test(pathname)) return { title: "正在打开报告", detail: "读取报告内容" };
if (pathname.startsWith("/reports")) return { title: "正在打开我的报告", detail: "读取报告列表" };
if (pathname.startsWith("/ephemeris")) return { title: "正在打开星历", detail: "计算今天的天象" };
if (pathname.startsWith("/rectification")) return { title: "正在打开生时校正", detail: "读取校正进度" };
if (pathname.startsWith("/synastry")) return { title: "正在打开婚恋合盘", detail: "读取合盘记录" };
return { title: "正在打开", detail: "" };
}
/** Milliseconds to wait before taking the curtain down; 0 = now. */
export function pageCurtainLeaveDelayMs(mode: PageCurtainMode, shownAt: number | null, now: number): number {
if (mode === "boot" || shownAt === null) return 0;
return Math.max(0, PAGE_CURTAIN_MIN_VISIBLE_MS - (now - shownAt));
}
@@ -33,23 +33,17 @@ test("the panel counts on desktop and tablet with the panel open and a complete
assert.equal(chartPanelPreloadApplicable({ viewportWidth: 1440, panelOpen: true, hasChart: false }), false);
});
test("the home preloads the panel behind the curtain and fades the curtain off the finished page", () => {
test("the home preloads the panel behind the curtain and holds the shell's page curtain until it is complete", () => {
const page = read("../src/app/(app)/page.tsx");
const frame = read("../src/components/chat-chart-frame.tsx");
const curtain = read("../src/components/boot-curtain.tsx");
assert.match(page, /void preloadChatChartPanel\(\)\.then/);
assert.match(page, /chartPanelApplicable,\n\s+chartPanelSettled,/);
assert.match(page, /\{coldBoot \? <BootCurtainExit /);
assert.match(page, /usePageCurtain\(booting, loadingCopy\);\n\s+if \(booting\) return null;/);
// The frame takes the preloaded module in its first render, not a frame later.
assert.match(frame, /useState<SplitComponent \| null>\(\(\) => peekChatChartSplit\(\)\)/);
// The leaving copy is not `.app-loading`, so the first-paint fallback never finds it.
assert.match(curtain, /className="boot-curtain is-leaving" aria-hidden="true"/);
assert.doesNotMatch(curtain, /className="app-loading[^"]*is-leaving/);
// No spinner after reveal: the leaving copy has a still ring, not the orbit.
assert.equal(curtain.split("BootCurtainExit")[1]?.includes("app-loading-orbit"), false);
const css = read("../src/app/globals.css");
assert.match(css, /\.boot-curtain \{ position: fixed; inset: 0;/);
assert.match(css, /prefers-reduced-motion: reduce\) \{\n\s+\.boot-curtain\.is-leaving/);
assert.match(css, /prefers-reduced-motion: reduce\) \{\n\s+\.boot-curtain\.is-entering, \.boot-curtain\.is-leaving/);
});
function runSidebarScript(storage: Record<string, string>) {
@@ -244,10 +244,12 @@ test("assertive announcement is reserved for genuine errors", () => {
// And: the still-loading screen stays polite and marks itself busy.
// 原值:page.tsx 内联 `<main className="app-loading" aria-busy="true" aria-live="polite">`
// 新值:同一段 main 在 boot-curtain.tsx(多一个 `boot-curtain` class),page.tsx 渲染 `<BootCurtain …>`
// 新值:main 在 app 外壳的 page-curtain.tsx(`app-loading boot-curtain`),page.tsx 只持有它:`usePageCurtain(booting, loadingCopy)`
// 原因:2026-11-05 全局启动幕布;polite 播报不变
assert.match(pageSource, /<BootCurtain title=/);
assert.match(readFileSync(new URL("../src/components/boot-curtain.tsx", import.meta.url), "utf8"), /<main className="app-loading boot-curtain" aria-busy="true" aria-live="polite">/);
assert.match(pageSource, /usePageCurtain\(booting, loadingCopy\);/);
const curtainSource = readFileSync(new URL("../src/components/page-curtain.tsx", import.meta.url), "utf8");
assert.match(curtainSource, /: `app-loading boot-curtain/);
assert.match(curtainSource, /<main className=\{className\} aria-busy=\{!leaving\} aria-live="polite"/);
});
test("the onboarding typewriter announces the finished line once instead of each character", () => {
+5 -5
View File
@@ -189,9 +189,9 @@ test("loading copy changes per phase so the single loading screen narrates progr
assert.doesNotMatch(`${account.detail}${prepare.detail}`, /今日星语/);
assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/);
// 原值:`<AppLoadingIndicator title={loadingCopy.title} detail={loadingCopy.detail} />`
// 新值:`<BootCurtain …>`,幕布内仍是同一个 AppLoadingIndicator
// 新值:首页持有外壳的整窗页面幕布 `usePageCurtain(booting, loadingCopy)`,文案仍按阶段变
// 原因:2026-11-05 全局启动幕布
assert.match(page, /<BootCurtain title=\{loadingCopy\.title\} detail=\{loadingCopy\.detail\} \/>/);
assert.match(page, /usePageCurtain\(booting, loadingCopy\);/);
});
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
@@ -245,10 +245,10 @@ test("recent sessions are prefetched after reveal so switches do not wait", () =
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
// 原值:page.tsx 内联 `<main className="app-loading" aria-busy="true" aria-live="polite">`
// 新值:同一段 main 在 boot-curtain.tsx,多一个 `boot-curtain` class 盖住整个窗口
// 新值:main 在外壳的 page-curtain.tsx(`app-loading boot-curtain`,整窗),首页只持有它
// 原因:2026-11-05 全局启动幕布;`main.app-loading[aria-busy="true"]` 与 `.app-loading-content` 仍在,恢复脚本照样能找到
assert.match(page, /<BootCurtain title=\{loadingCopy\.title\} detail=\{loadingCopy\.detail\} \/>/);
assert.match(readFileSync(new URL("../src/components/boot-curtain.tsx", import.meta.url), "utf8"), /<main className="app-loading boot-curtain" aria-busy="true" aria-live="polite">/);
assert.match(page, /usePageCurtain\(booting, loadingCopy\);/);
assert.match(readFileSync(new URL("../src/components/page-curtain.tsx", import.meta.url), "utf8"), /<main className=\{className\} aria-busy=\{!leaving\} aria-live="polite"/);
// 原值: 错误屏 main 写在 page.tsx。
// 新值: 同一段 main 在 home-bootstrap-error.tsx。
// 原因: 错误屏抽成组件,class 与 role 不变,僵死恢复脚本仍能找到它。
@@ -159,7 +159,7 @@ function createTab() {
}
type Tab = ReturnType<typeof createTab>;
type FirstCommit = { loading: boolean; title: string | null; url: string; requests: number; text: string };
type FirstCommit = { loading: boolean; held: boolean; title: string | null; url: string; requests: number; text: string };
async function openDocument(tab: Tab, href: string, requests: string[]) {
const h = createClientLifecycleHarness();
@@ -234,6 +234,11 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
const { registerShellNavigate } = await import("../src/lib/app-navigation.ts");
// 原值:外壳里没有加载幕布,首页自己渲染 `main.app-loading`。
// 新值:和 app/(app)/layout.tsx 一样在外壳里挂 PageCurtain,首页加载时只持有它。
// 原因:2026-11-05 全站统一的整窗加载幕布(BUG-1613)。
const { PageCurtain } = await import("../src/components/page-curtain.tsx");
const { usePageCurtainStore } = await import("../src/lib/page-curtain-store.ts");
// The layout's router.push: a navigation the sidebar starts by itself later
// (BUG-1509: 「新建对话」 after waiting for the prefetch) is queued here.
let queuedHref: string | null = null;
@@ -260,6 +265,7 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
so a transition that suspends keeps it instead of a fallback. */}
<Suspense fallback={null}>{children}</Suspense>
</SidebarInset>
<PageCurtain />
</main>
</SidebarProvider>
);
@@ -276,6 +282,8 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
return {
url: `${tab.location.pathname}${tab.location.search}`,
loading: byClass("app-loading").length > 0 || byClass("app-loading-orbit").length > 0,
// An in-app cold start holds the page curtain; it shows after PAGE_CURTAIN_SHOW_DELAY_MS (BUG-1613).
held: usePageCurtainStore.getState().holds.size > 0,
title,
inputDisabled: Boolean(textarea?.props.disabled),
rectificationSurface: byClass("is-rectification").length > 0,
@@ -293,7 +301,7 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
function FirstCommitProbe() {
useLayoutEffect(() => {
const v = view();
firstCommits.push({ loading: v.loading, title: v.title, url: v.url, requests: requests.length, text: h.container.text });
firstCommits.push({ loading: v.loading, held: v.held, title: v.title, url: v.url, requests: requests.length, text: h.container.text });
}, []);
return null;
}
@@ -562,9 +570,13 @@ test("a snapshot missing a piece falls back to the unchanged cold path", async (
// 新值: 点击先等重新发起的预取(至多 NEW_CHAT_WARM_WAIT_MS),预取挂住→到时限后经外壳导航,首页照旧冷启动
// 原因: BUG-1509——次级页「新建对话」会等进行中的预取,而不是在预取旁边冷启动首页;锁住的仍是「缺件就冷启动、不半揭幕」
assert.equal(await doc.shellNavigation(), "/?new=1");
assert.equal(doc.firstCommits[1].loading, true, "no snapshot → the loading ring, never a half-reveal");
// 原值:`.loading, true`(站内冷启动首帧就是加载圈) 新值:`.held, true`(首帧已持有整窗幕布,140ms 后才显示)
// 原因:2026-11-05 统一加载幕布,站内导航先等一小会儿,快的页面不闪幕布(BUG-1613);锁住的仍是「冷启动、不半揭幕」
assert.equal(doc.firstCommits[1].held, true, "no snapshot → the loading ring, never a half-reveal");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
assert.equal(doc.view().loading, true, "the cold path waits for the catalog as before (BUG-1021)");
// 原值:`.loading, true`(站内冷启动首帧就是加载圈) 新值:`.held, true`(首帧已持有整窗幕布,140ms 后才显示)
// 原因:2026-11-05 统一加载幕布,站内导航先等一小会儿,快的页面不闪幕布(BUG-1613);锁住的仍是「冷启动、不半揭幕」
assert.equal(doc.view().held, true, "the cold path waits for the catalog as before (BUG-1021)");
assert.equal(doc.view().title, null);
api.mode.hang.delete("/api/models");
// The cold path's own retry is out of scope; a new document proves it recovers.
@@ -816,9 +828,13 @@ test("a failed prefetch leaves nothing behind and 新建对话 takes the unchang
assert.equal(readHomeWarmSnapshot(USER), null, "the failed retry leaves nothing either");
api.mode.fail.delete("/api/models");
assert.equal(await doc.shellNavigation(), "/?new=1");
assert.equal(doc.firstCommits[0]?.loading, true, "without a snapshot Home cold-starts as before");
// 原值:`.loading, true`(站内冷启动首帧就是加载圈) 新值:`.held, true`(首帧已持有整窗幕布,140ms 后才显示)
// 原因:2026-11-05 统一加载幕布,站内导航先等一小会儿,快的页面不闪幕布(BUG-1613);锁住的仍是「冷启动、不半揭幕」
assert.equal(doc.firstCommits[0]?.held, true, "without a snapshot Home cold-starts as before");
await doc.settle(() => doc!.view().title === BLANK_HOME_TITLE, "the cold reveal");
assert.equal(doc.view().loading, false);
// 原值:揭幕后立刻 `loading === false` 新值:等幕布走完最短显示与淡出
// 原因:整窗幕布显示后至少停留 PAGE_CURTAIN_MIN_VISIBLE_MS 再淡出(BUG-1613)
await doc.settle(() => !doc!.view().loading, "the curtain fades off the revealed home");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
@@ -980,7 +996,9 @@ test("BUG-1509 (d): the wait is bounded — a prefetch that never answers still
assert.equal(await doc.shellNavigation(5000), "/?new=1");
const waited = Date.now() - started;
assert.ok(waited < 4000, `bounded wait (${waited} ms)`);
assert.equal(doc.firstCommits[0]?.loading, true, "no snapshot → the unchanged cold path");
// 原值:`.loading, true`(站内冷启动首帧就是加载圈) 新值:`.held, true`(首帧已持有整窗幕布,140ms 后才显示)
// 原因:2026-11-05 统一加载幕布,站内导航先等一小会儿,快的页面不闪幕布(BUG-1613);锁住的仍是「冷启动、不半揭幕」
assert.equal(doc.firstCommits[0]?.held, true, "no snapshot → the unchanged cold path");
for (let i = 0; i < 3; i += 1) await doc.h.idle();
assert.equal(doc.view().title, null, "the cold path still waits for the catalog (BUG-1021)");
assert.deepEqual(doc.errors, []);
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
PAGE_CURTAIN_MIN_VISIBLE_MS,
pageCurtainLeaveDelayMs,
routeCurtainCopy,
} from "../src/lib/page-curtain.ts";
import { latestPageCurtainCopy, usePageCurtainStore } from "../src/lib/page-curtain-store.ts";
// 2026-11-05(BUG-1613 第二轮):全站一个加载动画——北印度盘描线(画布方向 B)。
// 进页面等首批数据时,外壳里的整窗页面幕布盖住侧栏与页面;数据到了幕布淡出,页面一次完整出现。
// 页内切换(换人、切体系、开分盘与图层)仍是原来的骨架屏(产品:「切换的时候需要有骨架屏」)。
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("every app page entry names what it opens", () => {
assert.equal(routeCurtainCopy("/").title, "正在载入账户");
assert.equal(routeCurtainCopy("/chart").title, "正在打开星盘");
assert.equal(routeCurtainCopy("/people").title, "正在打开星盘档案");
assert.equal(routeCurtainCopy("/reports").title, "正在打开我的报告");
assert.equal(routeCurtainCopy("/reports/abc").title, "正在打开报告");
assert.equal(routeCurtainCopy("/reports/annual").title, "正在打开年运");
assert.equal(routeCurtainCopy("/ephemeris").title, "正在打开星历");
assert.equal(routeCurtainCopy("/rectification").title, "正在打开生时校正");
assert.equal(routeCurtainCopy("/synastry").title, "正在打开婚恋合盘");
});
test("a shown curtain stays its minimum time; the cold-load curtain leaves at once", () => {
assert.equal(pageCurtainLeaveDelayMs("boot", null, 1000), 0);
assert.equal(pageCurtainLeaveDelayMs("shown", 1000, 1100), PAGE_CURTAIN_MIN_VISIBLE_MS - 100);
assert.equal(pageCurtainLeaveDelayMs("shown", 1000, 1000 + PAGE_CURTAIN_MIN_VISIBLE_MS + 5), 0);
});
test("holds stack; the newest hold's copy is what the curtain says", () => {
const store = usePageCurtainStore.getState();
store.hold("a", { title: "甲" });
store.hold("b", { title: "乙" });
assert.equal(latestPageCurtainCopy(usePageCurtainStore.getState().holds)?.title, "乙");
store.release("b");
assert.equal(latestPageCurtainCopy(usePageCurtainStore.getState().holds)?.title, "甲");
store.release("a");
assert.equal(usePageCurtainStore.getState().holds.size, 0);
});
test("the shell mounts one curtain, in the server HTML, with the recovery DOM contract", () => {
assert.match(read("../src/app/(app)/layout.tsx"), /<PageCurtain \/>/);
const curtain = read("../src/components/page-curtain.tsx");
assert.match(curtain, /useState<PageCurtainMode>\("boot"\)/);
assert.match(curtain, /<ChartDrawLoader className="app-loading-chart" \/>/);
// Leaving drops `.app-loading` on the same element, so the motion never restarts.
assert.match(curtain, /\? "boot-curtain is-leaving"/);
const hook = read("../src/hooks/use-page-curtain.ts");
assert.match(hook, /useLayoutEffect/);
assert.match(hook, /PAGE_CURTAIN_MAX_HOLD_MS/);
});
test("page entries hold the curtain instead of drawing their own wait", () => {
assert.match(read("../src/components/secondary-page-shell.tsx"), /usePageCurtain\(Boolean\(waiting\)\);/);
assert.match(read("../src/components/secondary-page-shell.tsx"), /\{waiting \? null : children\}/);
assert.match(read("../src/components/personal-report/report-opening.tsx"), /usePageCurtain\(true, \{ title: "正在打开报告"/);
assert.doesNotMatch(read("../src/components/personal-report/report-opening.tsx"), /InlineSpinner/);
assert.match(read("../src/components/rectification-page/rectification-entry-page.tsx"), /usePageCurtain\(pending\);/);
assert.match(read("../src/components/synastry/synastry-picker.tsx"), /usePageCurtain\(access === "open" && \(!account \|\| catalog === null\)\);/);
assert.match(read("../src/components/annual/annual-report-page.tsx"), /\(!overview && !overviewError\)/);
});
test("the chart page holds the curtain on its first entry only; switching keeps the skeletons", () => {
const chart = read("../src/components/chart-page/chart-page-view.tsx");
assert.match(chart, /usePageCurtain\(!entryLayersDone && \(view == null \|\| !entryLayersSettled\)\);/);
assert.match(chart, /ChartWorkspaceSkeleton/);
// The skeleton motion stays (product, 2026-11-05).
assert.match(read("../src/components/chart-board/chart-board.css"), /animation: chart-skeleton-house 2\.4s/);
assert.match(read("../src/app/globals.css"), /\.chart-page-skeleton \.chart-skeleton-sector \{\n\s+fill: var\(--color-action-strong\);\n\s+fill-opacity: 0;\n\s+stroke-linejoin: round;\n\s+animation: chart-skeleton-house/);
});
test("the remaining in-page waits use the same chart-drawing motion", () => {
assert.match(read("../src/components/orbit-waiting.tsx"), /<ChartDrawLoader size=\{40\} \/>/);
assert.match(read("../src/components/app-loading-indicator.tsx"), /<ChartDrawLoader className="app-loading-chart" size=\{72\} \/>/);
assert.match(read("../src/components/synastry/synastry-wait.tsx"), /<ChartDrawLoader className="synastry-wait-chart" size=\{56\} \/>/);
assert.match(read("../src/components/personal-report/personal-report-page.tsx"), /<ChartDrawLoader size=\{48\} \/>/);
const css = read("../src/app/globals.css");
assert.match(css, /@keyframes chart-draw-diamond/);
assert.match(css, /\.chart-draw-line, \.chart-draw-lagna \{ animation: none; stroke-dashoffset: 0; \}/);
});
@@ -220,11 +220,13 @@ test("the bar is one cell per chapter, not a percentage, and nothing animates on
});
test("the waiting screen keeps its spinner and elapsed clock, and swaps in chapters only while writing", () => {
assert.match(pageSource, /<InlineSpinner className="text-primary" size=\{32\} \/>/);
// 原值:<InlineSpinner className="text-primary" size={32} /> 新值:<ChartDrawLoader size={48} />
// 原因:2026-11-05 全站统一加载动画(北印度盘描线 ChartDrawLoader + 整窗页面幕布,BUG-1613)
assert.match(pageSource, /<ChartDrawLoader size=\{48\} \/>/);
assert.match(pageSource, /PERSONAL_REPORT_GENERATING_COPY/);
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
assert.match(pageSource, /const writing = progress\?\.stage === "writing";/);
assert.match(pageSource, /\{writing \? null : <InlineSpinner/);
assert.match(pageSource, /\{writing \? null : <ChartDrawLoader/);
assert.match(pageSource, /\{progress && writing && <PersonalReportProgressPanel progress=\{progress\} \/>\}/);
// The chapter list lives in its own component; the page stays free of it.
assert.doesNotMatch(pageSource, /章节/);
@@ -100,7 +100,9 @@ test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () =>
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
// Original assertion required the fixed generating copy. Task 4 intentionally
// replaces it with phase-aware progress text while retaining the spinner.
assert.match(pageSource, /<InlineSpinner className="text-primary" size=\{32\} \/>/);
// 原值:<InlineSpinner className="text-primary" size={32} /> 新值:<ChartDrawLoader size={48} />
// 原因:2026-11-05 全站统一加载动画(北印度盘描线 ChartDrawLoader + 整窗页面幕布,BUG-1613)
assert.match(pageSource, /<ChartDrawLoader size=\{48\} \/>/);
assert.match(pageSource, /PERSONAL_REPORT_GENERATING_COPY/);
assert.doesNotMatch(pageSource, /章节/);
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
@@ -156,8 +156,11 @@ test("chart-type cells show the whole meaning line (two lines, CJK-safe line hei
test("chart, ephemeris and report waits show the orbit, the sentence only for screen readers", () => {
const orbit = read("../src/components/orbit-waiting.tsx");
assert.match(orbit, /<span className="sr-only">\{label\}<\/span>/);
assert.match(orbit, /app-loading-orbit/);
assert.match(read("../src/components/secondary-page-shell.tsx"), /<OrbitWaiting className="secondary-page-waiting" label=\{waiting\} \/>/);
// 原值:orbit-waiting 用 app-loading-orbit;次级页 waiting 渲染 <OrbitWaiting …>
// 新值:orbit-waiting 画 ChartDrawLoader;次级页 waiting 持有整窗页面幕布,正文留空
// 原因:2026-11-05 全站统一加载动画(北印度盘描线 ChartDrawLoader + 整窗页面幕布,BUG-1613)
assert.match(orbit, /<ChartDrawLoader size=\{40\} \/>/);
assert.match(read("../src/components/secondary-page-shell.tsx"), /usePageCurtain\(Boolean\(waiting\)\);/);
// 原值: /<OrbitWaiting className="chart-page-skeleton-copy" label=\{copy\} \/>/ (orbit logo over the empty chart)
// 新值: no orbit over the chart; the sentence is an sr-only span inside the role=status stage
// 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」——空盘本身就是等待动画