feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)

TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

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-29 00:03:46 +08:00
co-authored by Claude Opus 5.5
parent 1a4f77584b
commit dec36bce01
30 changed files with 670 additions and 165 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* The one line of birth data the cover prints (TASK-birth-sky-polish-20260928
* P2, product decision: date + time + city). Built on the server from the
* owned profile row; the client only ever receives the finished string.
*
* Format: `1994年5月18日 07:40 · 杭州`. Local birth time, 24-hour, zero-padded.
* No district, coordinates, timezone or name. When the city is not certain the
* caption stops at the time — we never guess.
*/
const SEPARATOR = " · ";
/** Province-level names whose own name is the city (municipalities, SARs). */
const CITY_PROVINCES: Readonly<Record<string, string>> = {
北京市: "北京",
天津市: "天津",
上海市: "上海",
重庆市: "重庆",
香港特别行政区: "香港",
澳门特别行政区: "澳门",
};
const CHINA_PROVINCE = /(省|自治区|市|特别行政区)$/;
const HAN = /[㐀-鿿]/;
/**
* City from a stored birth place label. Rules, most specific first:
*
* 1. Municipalities and SARs (`北京市 · 朝阳区`, `香港特别行政区`) → `北京`, `香港`.
* 2. Chinese labels `省 · 市 · 区` → the second segment when it ends in 「市」,
* without the 「市」 (`河北省 · 邯郸市 · 峰峰矿区` → `邯郸`). Prefectures that are
* not cities (盟、地区、自治州、县、林区) and province-only labels → none.
* 3. Overseas labels from the world table, `Country · Region · City`, exactly
* three segments with no Chinese characters → the third segment.
* 4. Anything else (empty, one segment, free text, unknown shape) → none.
*/
export function birthSkyCity(label: string | null | undefined): string | null {
if (typeof label !== "string") return null;
const segments = label.split(SEPARATOR).map((segment) => segment.trim()).filter(Boolean);
if (segments.length === 0) return null;
const [first, second, third] = segments;
if (CITY_PROVINCES[first]) return CITY_PROVINCES[first];
if (HAN.test(first)) {
if (!CHINA_PROVINCE.test(first) || segments.length < 2) return null;
if (!second.endsWith("市") || second.length < 2) return null;
return second.slice(0, -1);
}
if (segments.length === 3 && !segments.some((segment) => HAN.test(segment))) return third;
return null;
}
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const TIME = /^(\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/;
/** `1994年5月18日 07:40 · 杭州`, or null when the date or time is not well formed. */
export function birthSkyCaption(input: Readonly<{ date: string; time: string; placeLabel?: string | null }>): string | null {
const date = DATE.exec(input.date.trim());
const time = TIME.exec(input.time.trim());
if (!date || !time) return null;
const hour = Number(time[1]);
if (hour > 23) return null;
const moment = `${Number(date[1])}年${Number(date[2])}月${Number(date[3])}日 ${String(hour).padStart(2, "0")}:${time[2]}`;
const city = birthSkyCity(input.placeLabel);
return city ? `${moment}${SEPARATOR}${city}` : moment;
}
+13 -5
View File
@@ -3,10 +3,11 @@ import { z } from "zod";
/**
* 「那一刻的天空」 BFF contract (TASK-birth-sky-cover-20260928 T2).
*
* The response describes only the sky: altitudes, azimuths (degrees from
* north, eastward), star magnitudes. It never carries the birth date, time,
* coordinates or place — the server reads those from the owned profile row and
* keeps them.
* The response describes the sky: altitudes, azimuths (degrees from north,
* eastward), star magnitudes. The only birth data it carries is `caption`, one
* finished line (`1994年5月18日 07:40 · 杭州`, TASK-birth-sky-polish-20260928 P2)
* built on the server; no date, time, coordinate, timezone, district or name
* field ever leaves it.
*/
export const BIRTH_SKY_PHASES = ["day", "twilight_morning", "twilight_evening", "night"] as const;
@@ -45,6 +46,8 @@ export const birthSkyOkSchema = z.strictObject({
lines: z.array(birthSkyLineSchema).max(8),
/** `reported`: the birth time is the one the user typed, not an adopted one. */
timeBasis: z.enum(["adopted", "reported"]),
/** Printed under the fact line: local date, time and (when certain) city. */
caption: z.string().min(1).max(64),
});
export const BIRTH_SKY_FAILURES = [
@@ -73,7 +76,11 @@ const engineSchema = z.object({
lines: z.array(birthSkyLineSchema),
});
export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["timeBasis"]): BirthSkyOk | null {
export function birthSkyFromEngine(
payload: unknown,
timeBasis: BirthSkyOk["timeBasis"],
caption: string,
): BirthSkyOk | null {
const parsed = engineSchema.safeParse(payload);
if (!parsed.success) return null;
const candidate = {
@@ -84,6 +91,7 @@ export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["time
stars: parsed.data.stars.map(({ hr, altitude: alt, azimuth: az, magnitude }) => ({ hr, altitude: alt, azimuth: az, magnitude })),
lines: parsed.data.lines.map(({ id, strokes }) => ({ id, strokes })),
timeBasis,
caption,
};
const checked = birthSkyOkSchema.safeParse(candidate);
return checked.success ? checked.data : null;
+9 -8
View File
@@ -16,16 +16,16 @@ import {
* functions only; the overlay feeds them `requestAnimationFrame` time.
*/
export const BIRTH_SKY_CONVERGE_MS = 1_500;
export const BIRTH_SKY_TEXT_MS = 1_000;
export const BIRTH_SKY_FADE_MS = 500;
export const BIRTH_SKY_CONVERGE_MS = 3_000;
export const BIRTH_SKY_TEXT_MS = 2_500;
export const BIRTH_SKY_FADE_MS = 1_000;
export const BIRTH_SKY_REVEAL_TOTAL_MS = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS + BIRTH_SKY_FADE_MS;
/** Words fade in over the first part of their second, then hold. */
const TEXT_FADE_IN_MS = 400;
const TEXT_FADE_IN_MS = 600;
/** The layer itself comes in quickly so the page does not flash to dark. */
const LAYER_FADE_IN_MS = 200;
const LAYER_FADE_IN_MS = 400;
/** Reduced motion: no travel, one fade in, a hold, one fade out. */
const REDUCED_FADE_IN_MS = 400;
const REDUCED_FADE_IN_MS = 600;
/** Scattered stars stay inside the horizon so the clip never hides them. */
const SCATTER_RADIUS = BIRTH_SKY_RADIUS * 0.95;
@@ -118,8 +118,9 @@ export type BirthSkyRevealTimeline = Readonly<{
const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
/**
* Normal: 0–1.5 s converge (ease-out) with horizon, rings and letters fading
* in; 1.5–2.5 s words fade in and hold; 2.5–3 s the layer fades out.
* Normal (TASK-birth-sky-polish-20260928 P3, slowed from 1.5 + 1 + 0.5 s):
* 0–3 s converge (ease-out) with horizon, rings and letters fading in;
* 3–5.5 s words fade in and hold; 5.5–6.5 s the layer fades out.
* Reduced motion: the finished picture fades in, holds, fades out.
*/
export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline {
+7 -11
View File
@@ -4,7 +4,7 @@ import {
BIRTH_SKY_PLANET_NAMES,
BIRTH_SKY_TOP_LABEL,
birthSkyFactLine,
birthSkyFootnote,
birthSkyCaptionLine,
birthSkyHeadline,
} from "./sentence.ts";
@@ -68,8 +68,6 @@ export type BirthSkyDrawOptions = Readonly<{
palette: BirthSkyPaletteName | BirthSkyPalette;
width: number;
height: number;
/** Defaults to 「按你填写的时间」 when the birth time is not an adopted one. */
footnote?: string | null;
/** False when the heading serif did not load in time: draw in the sans stack. */
serif?: boolean;
}>;
@@ -105,7 +103,7 @@ export type BirthSkyFrame = Readonly<{
converge: number;
/** Horizon circle, altitude rings, direction letters, constellation lines, planet names. */
chrome: number;
/** Text frame: top label, rules, headline, fact line, footnote, disclaimer. */
/** Text frame: top label, rules, headline, fact line, caption, disclaimer. */
text: number;
/** Scattered start positions keyed by `birthSkyBodyKey`. */
scatter: ReadonlyMap<string, SkyPoint>;
@@ -153,7 +151,6 @@ export function drawBirthSky(
frame ? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.converge) : point
);
const serif = options.serif ?? true;
const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote;
const unit = options.width / UNIT_WIDTH;
ctx.save();
@@ -276,7 +273,7 @@ export function drawBirthSky(
ctx.fillText(text, CENTER_X + dx * (RADIUS + 18), CENTER_Y + dy * (RADIUS + 18) + 4);
}
// Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer.
// Text frame (sample C): label, rule, headline … rule, fact, caption, disclaimer.
ctx.textAlign = "left";
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 1 * textAlpha;
@@ -299,11 +296,10 @@ export function drawBirthSky(
ctx.font = font(600, 15, false);
ctx.fillText(birthSkyFactLine(sky), 48, 686, 504);
if (footnote) {
ctx.fillStyle = palette.inkSecondary;
ctx.font = font(400, 12.5, false);
ctx.fillText(footnote, 48, 712, 504);
}
// Birth caption (TASK-birth-sky-polish-20260928 P2), where the footnote used to be.
ctx.fillStyle = palette.inkSecondary;
ctx.font = font(400, 12.5, false);
ctx.fillText(birthSkyCaptionLine(sky), 48, 712, 504);
ctx.fillStyle = palette.inkTertiary;
ctx.font = font(400, 11, false);
+5 -3
View File
@@ -14,7 +14,8 @@ export const BIRTH_SKY_HEADLINES: Readonly<Record<BirthSkyPhase, string>> = {
export const BIRTH_SKY_TOP_LABEL = "JYOTISHA · 那 一 刻";
export const BIRTH_SKY_DISCLAIMER = "仅供自我探索,不替你做决定。";
export const BIRTH_SKY_REPORTED_FOOTNOTE = "按你填写的时间";
/** Appended to the caption when the time is an adopted rectified one. */
export const BIRTH_SKY_ADOPTED_SUFFIX = " · 校正后";
export const BIRTH_SKY_PLANET_NAMES: Readonly<Record<BirthSkyPlanetId, string>> = {
sun: "太阳",
@@ -55,6 +56,7 @@ export function birthSkyFactLine(sky: Pick<BirthSkyOk, "phase" | "planets">): st
return parts.join(",");
}
export function birthSkyFootnote(sky: Pick<BirthSkyOk, "timeBasis">): string | null {
return sky.timeBasis === "reported" ? BIRTH_SKY_REPORTED_FOOTNOTE : null;
/** The birth line under the fact line: the server's caption, plus 「· 校正后」 for an adopted time. */
export function birthSkyCaptionLine(sky: Pick<BirthSkyOk, "timeBasis" | "caption">): string {
return sky.timeBasis === "adopted" ? `${sky.caption}${BIRTH_SKY_ADOPTED_SUFFIX}` : sky.caption;
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { prepareChartViewProfile } from "../chart-view-profile-read.ts";
import { ConsultationSubjectError, subjectIdFromQuery, type ResolvedSubjectBirth } from "../subject-birth.ts";
import { birthSkyCaption } from "./caption.ts";
import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts";
/**
@@ -8,7 +9,8 @@ import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts";
* Birth truth is read on the server, from the owned profile row, exactly as the
* chart page reads it (`loadSubjectBirth` → `prepareChartViewProfile`). No
* client date or coordinate is accepted, so the endpoint cannot be used to draw
* an arbitrary sky. Never bills, never writes.
* an arbitrary sky. Never bills, never writes. The birth data it returns is
* the one `caption` line the cover prints, formatted here.
*/
export type BirthSkyEngineBody = Readonly<{
@@ -71,7 +73,8 @@ export async function assembleBirthSky(deps: BirthSkyDeps): Promise<BirthSkyResu
payload = null;
}
const timeBasis = ADOPTED_STATUSES.has(profile.birthTimeStatus ?? "") ? "adopted" : "reported";
const sky = payload == null ? null : birthSkyFromEngine(payload, timeBasis);
const caption = birthSkyCaption({ date: profile.date, time: profile.time, placeLabel: profile.placeLabel });
const sky = payload == null || !caption ? null : birthSkyFromEngine(payload, timeBasis, caption);
if (!sky) return { httpStatus: 503, body: { status: "unavailable" } };
return { httpStatus: 200, body: sky };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* When the redeem paywall opens (TASK-birth-sky-polish-20260928 P4).
*
* It no longer opens by itself when the starter home appears — that collided
* with the first-chart star convergence (BUG-1080). Instead the first send on
* this page that is refused for lack of credits (the pre-flight balance check
* or a 402) opens it; every later refusal opens billing as before. The caller
* keeps the typed question in the composer either way.
*/
export type RedeemPaywallGate = Readonly<{
uiPreview: boolean;
/** Page-lifetime flag: the paywall has been shown once. */
shown?: { current: boolean };
open?: (open: boolean) => void;
}>;
/** True when it opened the paywall; false means the caller opens billing. */
export function tryOpenRedeemPaywall(gate: RedeemPaywallGate): boolean {
if (gate.uiPreview || !gate.shown || !gate.open || gate.shown.current) return false;
gate.shown.current = true;
gate.open(true);
return true;
}