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:
co-authored by
Claude Opus 5.5
parent
1a4f77584b
commit
dec36bce01
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user