diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 89e1eea4..faad6639 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19674,3 +19674,19 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 +## BUG-1447 | 启程动效对所有人播放,慢网时先把内容藏起来 + +- 状态:resolved +- 首次发现:2026-10-26 +- 最近更新:2026-10-26 +- 影响面:已用过的账户也会看到追问进场、档案卡进场、星盘和大运画出、合盘头像靠拢。动效代码下载慢时,这些内容会先消失。 +- 用户现象:老用户打开页面也会播一遍新用户才有的动画。网络慢时,合盘报告或追问胶囊会一直空白,直到动画代码下载完。 +- 触发条件:动效监听挂在每个已登录会话上。播放前先把元素透明度设成 0,再去加载动画代码。 +- 根因:没有区分是不是还在启程里。藏内容发生在拿到动画函数之前。 +- 修复:清单已关就不播。清单还开着且有未完成项,或账户还没完成第一问,才播。先拿到动画函数再隐藏;加载失败或超过 300 毫秒还没到,就直接显示、不播。 +- 验证:`frontend/tests/onboarding-motion-gate.test.tsx`、`onboarding-motion-20261022.test.ts`、`onboarding-hint-observer.test.tsx` 共 10 通过、0 失败、退出码 0。清单已关时四处动画都没被调用。加载失败或超过 300 毫秒时,元素透明度不是 0。 +- 防复发:上面两组测试。 +- 相关记录:issue #111 验收 U4。#99。红线 4。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index 3f8aa48f..a8e8cc85 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -31,3 +31,11 @@ | 检查 | 命令 | 结果 | | --- | --- | --- | | 提示监听 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-hint-observer.test.tsx tests/onboarding-checklist-20261022.test.ts` | 14 通过,0 失败,退出码 0 | + +## U4 · 启程动效只在启程里播(BUG-1447) + +清单已关不播。还在启程里才播。动画代码没到手或超过 300 毫秒,内容保持可见。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 动效范围 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-motion-gate.test.tsx tests/onboarding-motion-20261022.test.ts tests/onboarding-hint-observer.test.tsx` | 10 通过,0 失败,退出码 0 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f60b5733..a421b1f9 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–U3 已提交) | `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–U4 已提交) | `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/DESIGN.md b/frontend/DESIGN.md index a67c09bb..a3cec9b0 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1283,7 +1283,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` 一次性提示用现有弹出层,宽 `min(18rem, calc(100vw - 2rem))`,不抢焦点。侧栏两项从锚点右侧伸出,其余从上方伸出,间距 8px。按钮是「知道了」。同一时刻最多一条。天空过场、兑换窗、任何弹窗或抽屉、启程清单正打开、回答还在生成时,提示不出;挡着的那一条等条件过去再出。侧栏「星盘」、手机菜单和星盘四个页签上的小圆点是 8px,贴在右上角 4px,不是字符。 -启程专用动效只在元素第一次出现时播放。重绘、来回切换不重播。除回答流式中的呼吸点外,没有无限循环。只动 `opacity`、`transform`、颜色、`clip-path`,不触发布局。Motion 用到才加载,不进首屏。系统开启「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。 +启程专用动效只在启程里播放:清单还开着且有未完成项,或账户还没完成第一问。清单已经关掉的账户不播追问进场、档案卡进场、星盘和大运画出、合盘头像靠拢。动效代码还没到手、或 300ms 内没到手时,内容保持可见,不先藏起来。只在元素第一次出现时播放。重绘、来回切换不重播。除回答流式中的呼吸点外,没有无限循环。只动 `opacity`、`transform`、颜色、`clip-path`,不触发布局。Motion 用到才加载,不进首屏。系统开启「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。 | 类别 | 用在哪 | 时长 | 曲线 | | --- | --- | --- | --- | diff --git a/frontend/src/components/onboarding-motion-host.tsx b/frontend/src/components/onboarding-motion-host.tsx index eb2548a3..cf391f70 100644 --- a/frontend/src/components/onboarding-motion-host.tsx +++ b/frontend/src/components/onboarding-motion-host.tsx @@ -2,10 +2,15 @@ import { useEffect } from "react"; +import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; import { mountOnboardingMotion } from "@/lib/motion/onboarding-run"; -/** Listens for first-appearance beats. Motion itself loads on the first one. */ +/** Listens for first-appearance beats. Only during onboarding. Motion loads on the first one. */ export function OnboardingMotionHost() { - useEffect(() => mountOnboardingMotion(document), []); + const enabled = useOnboardingJourneyOptional()?.motionEnabled === true; + useEffect(() => { + if (!enabled) return; + return mountOnboardingMotion(document); + }, [enabled]); return null; } diff --git a/frontend/src/lib/motion/onboarding-run.ts b/frontend/src/lib/motion/onboarding-run.ts index ea7f3084..0fe828b9 100644 --- a/frontend/src/lib/motion/onboarding-run.ts +++ b/frontend/src/lib/motion/onboarding-run.ts @@ -26,6 +26,38 @@ async function animator(): Promise { return animateFn; } +const MOTION_WAIT_MS = 300; +let motionLoader: () => Promise = () => animator(); + +/** Tests replace the loader. Production uses the real Motion import. */ +export function setOnboardingMotionLoader(next: (() => Promise) | null): void { + motionLoader = next ?? (() => animator()); +} + +async function motionReady(): Promise { + let timer: ReturnType | undefined; + const attempt = motionLoader().then(() => true).catch(() => false); + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), MOTION_WAIT_MS); + }); + try { + return await Promise.race([attempt, expired]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** Hide only after Motion is in hand. A failed or slow load leaves the element visible. */ +async function conceal(node: HTMLElement): Promise { + const ready = await motionReady(); + if (!ready) { + node.style.opacity = ""; + return false; + } + node.style.opacity = "0"; + return true; +} + function easeOf(spec: OnboardingSpec): [number, number, number, number] { return [spec.ease[0], spec.ease[1], spec.ease[2], spec.ease[3]]; } @@ -53,7 +85,7 @@ async function playEnter(node: HTMLElement): Promise { if (!key || !claimOnboardingPlay(`enter:${key}`)) return; const spec = specFor("enter", indexOf(node)); if (!spec) return; - node.style.opacity = "0"; + if (!await conceal(node)) return; if (spec.y) node.style.transform = `translateY(${spec.y[0]}px)`; try { await run(node, spec, spec.y ? { opacity: [...spec.opacity!], y: [...spec.y] } : { opacity: [...spec.opacity!] }); @@ -68,7 +100,7 @@ async function playPop(node: HTMLElement): Promise { if (!key || !claimOnboardingPlay(`pop:${key}`)) return; const spec = specFor("pop"); if (!spec) return; - node.style.opacity = "0"; + if (!await conceal(node)) return; try { await run(node, spec, spec.scale ? { opacity: [...spec.opacity!], scale: [...spec.scale] } @@ -92,7 +124,7 @@ async function playDraw(node: HTMLElement, kind: "intake" | "vedic" | "western" const spec = specFor("draw", indexOf(node), kind); if (!spec) return; if (!spec.clipPath) { - node.style.opacity = "0"; + if (!await conceal(node)) return; try { await run(node, spec, { opacity: [0, 1] }); } finally { @@ -222,7 +254,7 @@ async function playCelebrate(): Promise { } async function fadeIn(node: HTMLElement, spec: OnboardingSpec): Promise { - node.style.opacity = "0"; + if (!await conceal(node)) return; try { await run(node, spec, { opacity: [0, 1] }); } finally { @@ -242,7 +274,7 @@ async function playNameFollow(node: HTMLElement): Promise { const from = field.getBoundingClientRect(); const to = node.getBoundingClientRect(); const text = node.textContent?.trim() || ""; - node.style.opacity = "0"; + if (!await conceal(node)) return; try { await flyRect(from, to, text, spec); } finally { @@ -289,7 +321,7 @@ async function playStarterFollow(bubble: HTMLElement): Promise { await fadeIn(bubble, spec); return; } - bubble.style.opacity = "0"; + if (!await conceal(bubble)) return; try { await flyRect(pending.rect, bubble.getBoundingClientRect(), pending.text, spec); } finally { @@ -303,7 +335,7 @@ async function playSynastry(article: HTMLElement): Promise { const spec = specFor("synastry"); if (!spec) return; if (!spec.x || !spec.scale) { - article.style.opacity = "0"; + if (!await conceal(article)) return; try { await run(article, spec, { opacity: [0, 1] }); } finally { @@ -311,6 +343,7 @@ async function playSynastry(article: HTMLElement): Promise { } return; } + if (!await conceal(article)) return; const title = article.querySelector("h1")?.textContent ?? ""; const sides = title.split("×").map((part) => part.replace(/·[\s\S]*$/, "").trim()); const marks = [sides[0]?.slice(0, 1) || "甲", sides[1]?.slice(0, 1) || "乙"]; @@ -335,7 +368,6 @@ async function playSynastry(article: HTMLElement): Promise { glow.style.opacity = "0"; layer.append(left, right, glow); document.body.appendChild(layer); - article.style.opacity = "0"; const approach = onboardingMotion.synastryMs / 1000; try { await Promise.all([ @@ -405,6 +437,8 @@ export async function playRing(node: HTMLElement, from: string, to: string): Pro } } +export const playOnboardingEnter = playEnter; + export function mountOnboardingMotion(root: ParentNode = document): () => void { const visit = (node: HTMLElement) => { scan(node); diff --git a/frontend/src/lib/onboarding-hints.ts b/frontend/src/lib/onboarding-hints.ts index 918ceed9..620352ee 100644 --- a/frontend/src/lib/onboarding-hints.ts +++ b/frontend/src/lib/onboarding-hints.ts @@ -159,6 +159,17 @@ export function queuedHint(facts: HintFacts): OnboardingHintId | null { return eligible({ ...facts, blocked: false, anchors: {} }); } +/** Motion plays only inside onboarding: an open checklist with work left, or an account still before its first question. A closed checklist never plays. */ +export function onboardingMotionEnabled(input: { + readonly checklistDismissed: boolean; + readonly checklistOpen: boolean; + readonly unfinishedItem: boolean; + readonly beforeFirstQuestion: boolean; +}): boolean { + if (input.checklistDismissed) return false; + return (input.checklistOpen && input.unfinishedItem) || input.beforeFirstQuestion; +} + /** No observer when every hint was seen, the checklist was closed, nothing is eligible, or an answer is streaming. */ export function shouldAttachHintObserver(input: { readonly checklistClosed: boolean; diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx index 069625aa..207f92b8 100644 --- a/frontend/src/lib/onboarding-journey.tsx +++ b/frontend/src/lib/onboarding-journey.tsx @@ -29,6 +29,7 @@ import { hintCopy, hintsBlocked, nextEphemerisPhase, + onboardingMotionEnabled, selectHint, shouldAttachHintObserver, showChartMenuDot, @@ -103,6 +104,8 @@ export type OnboardingJourney = { readonly registerHome: (actions: HomeActions | null) => void; /** False when no hint can appear, the checklist was closed, or an answer is streaming. */ readonly hintWatch: boolean; + /** False for a closed checklist and for anyone past onboarding. */ + readonly motionEnabled: boolean; }; export const OnboardingJourneyContext = createContext(null); @@ -546,6 +549,12 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea streaming: live.streaming, facts: hintFacts, }); + const motionEnabled = onboardingMotionEnabled({ + checklistDismissed: Boolean(merged.checklistDismissedAt), + checklistOpen: showChecklist, + unfinishedItem: items.some((item) => !item.done), + beforeFirstQuestion: !hasAnswer && remote.status === "ready" && userId !== "", + }); const showChecklist = remote.status === "ready" && shouldShowChecklist({ profileComplete, @@ -656,6 +665,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea setLive, registerHome, hintWatch, + motionEnabled, }), [ showChecklist, items, @@ -674,6 +684,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea setLive, registerHome, hintWatch, + motionEnabled, ]); return {children}; diff --git a/frontend/tests/onboarding-hint-observer.test.tsx b/frontend/tests/onboarding-hint-observer.test.tsx index 91703f88..5c7e37c9 100644 --- a/frontend/tests/onboarding-hint-observer.test.tsx +++ b/frontend/tests/onboarding-hint-observer.test.tsx @@ -44,6 +44,7 @@ function journey(hintWatch: boolean): OnboardingJourney { setLive: () => {}, registerHome: () => {}, hintWatch, + motionEnabled: false, }; } diff --git a/frontend/tests/onboarding-motion-gate.test.tsx b/frontend/tests/onboarding-motion-gate.test.tsx new file mode 100644 index 00000000..e6fd93e3 --- /dev/null +++ b/frontend/tests/onboarding-motion-gate.test.tsx @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OnboardingMotionHost } from "../src/components/onboarding-motion-host.tsx"; +import { onboardingMotionEnabled } from "../src/lib/onboarding-hints.ts"; +import { OnboardingJourneyContext, type OnboardingJourney } from "../src/lib/onboarding-journey.tsx"; +import { claimOnboardingPlay, resetOnboardingPlays } from "../src/lib/motion/onboarding-play.ts"; +import { playOnboardingEnter, setOnboardingMotionLoader } from "../src/lib/motion/onboarding-run.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +function closedJourney(): OnboardingJourney { + return { + showChecklist: false, + items: [], + label: "", + celebration: false, + activeHint: null, + hintText: "", + showChartDot: false, + tabHasDot: () => false, + dismissChecklist: () => {}, + dismissHint: async () => true, + go: () => {}, + setChartTab: () => {}, + markChartVisited: () => {}, + setChecklistOpen: () => {}, + setLive: () => {}, + registerHome: () => {}, + hintWatch: false, + motionEnabled: onboardingMotionEnabled({ + checklistDismissed: true, + checklistOpen: false, + unfinishedItem: true, + beforeFirstQuestion: true, + }), + }; +} + +test("a closed checklist plays none of the four onboarding beats", async () => { + const journey = closedJourney(); + assert.equal(journey.motionEnabled, false); + resetOnboardingPlays(); + const harness = createClientLifecycleHarness(); + try { + await harness.render( + + + +
档案
+ +
合盘
+
, + ); + await harness.idle(); + assert.equal(claimOnboardingPlay("enter:follow-up"), true); + assert.equal(claimOnboardingPlay("enter:archive"), true); + assert.equal(claimOnboardingPlay("draw:vedic"), true); + assert.equal(claimOnboardingPlay("syn:00000000-0000-4000-8000-000000001447"), true); + assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n")); + } finally { + await harness.close(); + } +}); + +test("a failed or slow Motion load leaves the element visible", async () => { + const harness = createClientLifecycleHarness(); + try { + resetOnboardingPlays(); + setOnboardingMotionLoader(() => Promise.reject(new Error("motion failed"))); + const failed = harness.document.createElement("div"); + failed.setAttribute("data-onboarding-enter", "follow-up"); + failed.style.opacity = "1"; + await playOnboardingEnter(failed as unknown as HTMLElement); + assert.notEqual(failed.style.opacity, "0"); + + resetOnboardingPlays(); + setOnboardingMotionLoader(() => new Promise(() => {})); + const slow = harness.document.createElement("div"); + slow.setAttribute("data-onboarding-enter", "archive"); + slow.style.opacity = "1"; + const started = Date.now(); + await playOnboardingEnter(slow as unknown as HTMLElement); + assert.ok(Date.now() - started >= 250); + assert.notEqual(slow.style.opacity, "0"); + } finally { + setOnboardingMotionLoader(null); + await harness.close(); + } +});