feat(loading): chart wait lights houses up in order, people archive uses the orbit, today's line no longer jumps in after reveal (BUG-1125)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 13:45:55 +08:00
co-authored by Claude Opus 5.5
parent 9a616107c2
commit d327ca9c95
17 changed files with 200 additions and 37 deletions
+33 -14
View File
@@ -986,6 +986,7 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4
.chat-profile-picker-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 500; }
.chat-profile-picker-status { margin: var(--space-2) 0 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.45; }
.chat-profile-picker-status p { margin: 0; }
.chat-profile-picker-waiting { margin-top: var(--space-2); }
.chat-profile-picker-retry { min-height: 44px; margin-top: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-action); cursor: pointer; font: inherit; font-size: var(--type-caption); }
.chat-profile-picker-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; }
.chat-profile-picker-option { width: 100%; min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font: inherit; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
@@ -2413,6 +2414,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* The line enters softly (BUG-1125): opacity and a 4px rise, 240ms, whenever
its text first appears or changes. No placeholder, no spinner. */
.starter-greeting-sub { animation: starter-greeting-sub-in 240ms var(--ease-out) both; }
@keyframes starter-greeting-sub-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.starter-greeting-sub { animation: none; }
}
/* Entry pills, under the composer. The two product entrances keep their place
in the product but not their weight: they were 132px cards competing with the
@@ -5361,16 +5372,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
width: 100%;
min-width: 0;
}
.chart-page-skeleton {
animation: chart-skeleton-breathe 2.4s ease-in-out infinite;
}
/* The waiting chart (2026-09-30, 宫位依次点亮): the empty drawing's lines stay
still and faint while the twelve houses (the western wheel's twelve sectors)
take turns glowing in house order, 150ms apart, one wave every 2.4s. Only
the fill moves; the box is the finished chart's box (BUG-1101). */
.chart-page-skeleton svg { display: block; margin-top: 0; }
@keyframes chart-skeleton-breathe {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.6; }
.chart-page-skeleton .personal-report-chart-cell,
.chart-page-skeleton .chart-page-western-ring,
.chart-page-skeleton .chart-page-western-spoke { stroke-opacity: 0.55; }
.chart-page-skeleton .personal-report-chart-cell,
.chart-page-skeleton .chart-skeleton-sector {
fill: var(--color-action-strong);
fill-opacity: 0;
stroke-linejoin: round;
animation: chart-skeleton-house 2.4s ease-in-out infinite;
animation-delay: calc(var(--house-index, 0) * 150ms);
}
.chart-page-skeleton .chart-skeleton-sector { stroke: none; }
@keyframes chart-skeleton-house {
0%, 40%, 100% { fill-opacity: 0; }
14% { fill-opacity: 0.18; }
}
@media (prefers-reduced-motion: reduce) {
.chart-page-skeleton { animation: none; opacity: 0.5; }
.chart-page-skeleton .personal-report-chart-cell,
.chart-page-skeleton .chart-skeleton-sector { animation: none; fill-opacity: 0; }
}
/* 「那一刻的天空」: the chart header's one extra action, tinted with the accent and
a sparkle so it reads as an invitation rather than a thin outline (2026-09-29).
@@ -5424,14 +5449,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
width: max-content;
}
.chart-symbol-bubble.is-below { transform: translate(-50%, 8px); }
/* Waiting copy sits over the breathing empty chart, centred, so the waiting
stage and the finished one are the same height. */
/* The waiting stage carries only an sr-only sentence next to the drawing. */
.chart-page-waiting-plot { position: relative; }
.chart-page-skeleton-copy {
position: absolute;
inset: 50% var(--space-4) auto;
transform: translateY(-50%);
}
.chart-page-symbol-hit { fill: transparent; stroke: none; }
.chart-page-symbol-target { cursor: help; }
.chart-page-symbol-target:focus-visible {
@@ -1,20 +1,20 @@
import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "./western-wheel-svg";
import { OrbitWaiting } from "@/components/orbit-waiting";
/** Only chart positions opt in; the shared page shell remains static. */
export function ChartPageSkeleton({ copy, western = false }: { copy: string; western?: boolean }) {
return (
<div className={western ? "chart-page-western-layout" : "chart-page-vedic-layout"}>
<div className={`chart-page-waiting-plot ${western ? "chart-page-western-wheel" : "chart-page-vedic-stage"}`} aria-busy="true" role="status">
{/* The empty drawing is the whole wait (2026-09-30, product: 「星盘的加载动画
太丑了」, chose 宫位依次点亮): faint static lines while the houses light up
one after another in house order. No orbit logo over it any more. Same
box as the finished chart, so the reveal does not jump (BUG-1101). */}
<div className={western ? "chart-page-skeleton" : "chart-page-skeleton chart-page-vedic-plot"} aria-hidden="true">
{western ? <WesternWheelSvg western={EMPTY_WESTERN_WHEEL} skeleton />
: <VedicChartSvg chart={EMPTY_NORTH_INDIAN_CHART} ariaLabel={copy} skeleton />}
</div>
{/* Over the empty drawing, not under it, so the waiting stage and the
finished one are the same height (BUG-1101). An orbit, not a sentence
(2026-09-30); the sentence is for screen readers. */}
<OrbitWaiting className="chart-page-skeleton-copy" label={copy} />
<span className="sr-only">{copy}</span>
</div>
</div>
);
@@ -1,4 +1,4 @@
import type { MouseEvent, PointerEvent } from "react";
import type { CSSProperties, MouseEvent, PointerEvent } from "react";
import type { ChartViewWesternOk } from "@/lib/chart-view-contract";
import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels";
@@ -183,6 +183,15 @@ export function WesternWheelSvg({
/>
);
})}
{/* Waiting wheel only: twelve sectors that light up in turn around the circle. */}
{skeleton && SIGN_ORDER.map((sign, index) => (
<path
key={`${sign}-waiting`}
d={westernRingSlice(WESTERN_R_ZODIAC, WESTERN_R_OUTER, westernScreenAngle(index * 30 + 30, asc), westernScreenAngle(index * 30, asc))}
className="chart-skeleton-sector"
style={{ "--house-index": index } as CSSProperties}
/>
))}
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_OUTER} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_ZODIAC} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_HOUSE} className="chart-page-western-ring" />
@@ -5,6 +5,7 @@ import { useEffect } from "react";
import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject";
import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent";
import { missingOtherProfileStep } from "@/lib/home-profile";
import { SELF_SUBJECT_ID } from "@/lib/subject-birth";
import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage";
import type { BootstrapPhase } from "@/lib/home-bootstrap";
import { readHomeWarmDaily, rememberHomeWarmDaily } from "@/lib/home-warm-snapshot";
@@ -36,7 +37,18 @@ export function DailyStarlanguageBinder(props: {
const natalMinuteAvailable = route?.kind === "consult" && Boolean(route.time);
useEffect(() => {
if (bootstrapPhase === "account" || !accountId || !subjectReady || !profile || !profileComplete || !natalMinuteAvailable) {
// Only another person needs the catalog; the owner's profile is already here.
// While it loads the card is "pending", not "unavailable": "unavailable" counted
// as settled, the home revealed without the line, and the line jumped in under
// the greeting a moment later (BUG-1125). Pending holds the reveal (bounded by
// the prepare timeout) so the line arrives with the first frame.
const waitingForCatalog = !subjectReady && subjectId !== SELF_SUBJECT_ID;
if (bootstrapPhase !== "account" && accountId && waitingForCatalog) {
let cancelled = false;
queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "pending" }); });
return () => { cancelled = true; };
}
if (bootstrapPhase === "account" || !accountId || waitingForCatalog || !profile || !profileComplete || !natalMinuteAvailable) {
let cancelled = false;
queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "unavailable" }); });
return () => { cancelled = true; };
@@ -298,8 +298,8 @@ export function PeoplePage() {
}
return (
<SecondaryPageShell title="星盘档案">
{status === "loading" ? <p className="secondary-page-waiting">人物列表还在读</p> : null}
// The shared waiting orbit, like 星盘 / 星历 / 报告 (2026-09-30, product: 「星盘档案这里也要加一个加载动画」).
<SecondaryPageShell title="星盘档案" waiting={status === "loading" ? "人物列表还在读" : null}>
{status === "failed" ? (
<section className="chart-page-message">
<p>人物列表暂时读不出来</p>
@@ -16,7 +16,7 @@
* Colour identifies a body. It is not a benefic/malefic signal.
*/
import { useId } from "react";
import { useId, type CSSProperties } from "react";
import type { ChartV1 } from "@/lib/personal-report-contract";
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
@@ -364,6 +364,8 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh
<polygon
points={polygonPoints(polygon)}
className={`personal-report-chart-cell${highlightHouseNumbers?.has(houseNumber) ? " is-changed" : ""}`}
// The waiting chart lights the houses up in house order (chart-skeleton-house).
style={skeleton ? ({ "--house-index": houseNumber - 1 } as CSSProperties) : undefined}
/>
{ordinal !== null && (
<g
+3 -1
View File
@@ -36,7 +36,9 @@ export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrend
<section className="starter-greeting-block" aria-labelledby="starter-heading">
<h1 id="starter-heading">{starterGreeting.text}</h1>
{dailyTrend ? (
<p className="starter-greeting-sub" role={dailyTrendSettled ? undefined : "status"}>
// Keyed on the text: a line that arrives or changes after the first frame
// fades in instead of snapping in (BUG-1125).
<p key={dailyTrend} className="starter-greeting-sub" role={dailyTrendSettled ? undefined : "status"}>
{dailyTrend}
</p>
) : null}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type ChatProfileCatalogStatus,
type ChatProfilePickerRow,
} from "@/lib/chat-profile-picker-model";
import { OrbitWaiting } from "@/components/orbit-waiting";
import type { ChartLibraryRecord } from "@/lib/home-types";
export type SubjectSwitcherProps = {
@@ -88,7 +89,7 @@ export function SubjectSwitcher({
}}
>
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
{status === "loading" ? <p className="chat-profile-picker-status" role="status">人物列表还在读</p> : null}
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
{status === "failed" ? (
<div className="chat-profile-picker-status" role="status">
<p>人物列表暂时读不出来</p>