fix(ui): keep mobile onboarding and login usable
Independent Staging Quality Gate / validate (push) Failing after 12m37s
Independent Staging Quality Gate / publish (push) Has been skipped

Hide the dead composer during birth-time intake, replace internal
copy with user-facing hints, and keep short-screen login, location
search, and session history reachable on phones.
This commit is contained in:
Jesse_Chen
2026-08-17 11:41:35 +08:00
parent 7050f7ee17
commit bced1b9d57
9 changed files with 132 additions and 30 deletions
+45 -21
View File
@@ -274,8 +274,9 @@ button:disabled { cursor: default; opacity: .45; }
@media (max-width: 767px) {
.brand-row { padding: 0 4px; }
.brand-mark { width: 26px; height: 26px; }
.session-nav { overflow: hidden; }
.session-nav { overflow: visible; }
.session-list { overflow-x: clip; }
[data-sidebar="content"] { -webkit-overflow-scrolling: touch; }
.chat-header > div { min-width: 0; flex: 1; }
.chat-header strong { max-width: 100%; }
.onboarding-card { padding: 16px; }
@@ -294,8 +295,7 @@ button:disabled { cursor: default; opacity: .45; }
.profile-grid, .location-grid, .code-form { grid-template-columns: 1fr; }
.code-form .note-field { grid-column: auto; }
.code-form .button-primary { width: 100%; }
.auth-page { padding: 16px; }
.auth-brand { margin-bottom: 32px; }
.auth-brand { margin-bottom: var(--space-6); }
}
@media (prefers-reduced-motion: reduce) {
@@ -825,8 +825,8 @@ button:disabled { cursor: default; opacity: .45; }
.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; }
.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
.button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 15px; border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: 14px; font-weight: 500; }
.button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); }
.button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); }
@@ -925,17 +925,19 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
.message-content { max-width: 88%; }
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
:root { --composer-reserve: 116px; }
.location-combobox-results { position: static; top: auto; max-height: min(240px, 42dvh); margin-top: var(--space-2); box-shadow: none; }
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 72px !important; height: 72px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.auth-page { padding: 0; }
.auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); }
.auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; }
.auth-shell { min-height: 100%; overflow: visible; align-content: start; grid-template-columns: 1fr; grid-template-rows: auto auto; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 0; justify-content: flex-start; gap: var(--space-3); padding: var(--space-6) var(--space-6) var(--space-5); }
.auth-story h2 { max-width: 560px; margin: var(--space-3) 0 0; font-size: var(--type-display-sm); }
.auth-story > div > p, .auth-footnote { display: none; }
.auth-panel { padding: var(--space-8) var(--space-6); }
.auth-panel { justify-content: flex-start; padding: var(--space-8) var(--space-6) max(var(--space-8), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-md); }
.admin-header { padding: 0 var(--space-4); }
.admin-scroll { padding: var(--space-6) var(--space-4) var(--space-12); }
@@ -959,15 +961,20 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.paywall-footer > a, .paywall-footer > button { width: 100%; }
.dialog-actions { flex-direction: column-reverse; }
.dialog-actions > button { width: 100%; }
.auth-story { min-height: 220px; padding: var(--space-6) var(--space-5); }
.auth-story h2 { font-size: var(--type-display-sm); }
.auth-panel { padding: var(--space-8) var(--space-5); }
.auth-story { min-height: 0; padding: var(--space-5); }
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-sm); }
.admin-section { padding: var(--space-5); }
}
.session-delete-overlay { z-index: 100; }
.session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); }
@media (max-width: 767px) and (max-height: 640px) {
.auth-story { display: none; }
.auth-brand { display: flex; margin-bottom: var(--space-6); }
}
@media (max-width: 430px) {
[data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; }
.conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; }
@@ -1571,18 +1578,36 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.onboarding-card-heading b { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.03em; }
.onboarding-card-heading small { max-width: 260px; color: var(--color-ink-secondary); line-height: 1.55; font-size: var(--type-caption); text-wrap: pretty; }
.birth-time-intake { gap: var(--space-6); }
.conversation.is-onboarding-form { align-content: start; padding-bottom: var(--space-8); }
.conversation.is-onboarding-form .welcome { padding-top: var(--space-6); padding-bottom: var(--space-8); }
.conversation.is-onboarding-form .welcome > .onboarding-message:first-child { padding-bottom: var(--space-3); }
.conversation.is-onboarding-form .welcome > .onboarding-message:first-child .message-bubble p {
max-width: 680px;
font-size: var(--type-title-md);
letter-spacing: -.2px;
line-height: 1.4;
}
.birth-time-source-fieldset { padding-top: var(--space-5); border-top: 1px solid var(--color-border); }
.birth-time-source-fieldset legend { margin-bottom: 5px; color: var(--color-ink); font-size: var(--type-title-sm); font-weight: 700; }
.birth-time-source-fieldset legend {
display: block;
float: none;
width: 100%;
margin: 0 0 5px;
padding: 0;
color: var(--color-ink);
font-size: var(--type-title-sm);
font-weight: 700;
}
.birth-time-source-intro { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; }
.birth-time-source-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.birth-time-source-option { min-height: 118px; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: var(--space-3); padding: 18px; border-color: var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); transition: border-color 160ms ease-out, background-color 160ms ease-out; }
.birth-time-source-list { grid-template-columns: 1fr; gap: var(--space-2); }
.birth-time-source-option { min-height: 64px; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-color: var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); transition: border-color 160ms ease-out, background-color 160ms ease-out; }
.birth-time-source-option:hover { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); }
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action); }
.birth-time-source-option input { appearance: none; position: relative; width: 16px; min-width: 16px; height: 16px; min-height: 16px; margin: 3px 0 0; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); }
.birth-time-source-option input { appearance: none; align-self: center; justify-self: center; position: static; box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 16px; min-height: 16px; max-height: 16px; margin: 0; overflow: visible; clip: auto; clip-path: none; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); }
.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action); box-shadow: inset 0 0 0 4px var(--color-action-soft); }
.birth-time-source-option > span { gap: 7px; }
.birth-time-source-option b { font-size: var(--type-title-sm); line-height: 1.3; }
.birth-time-source-option small { font-size: var(--type-body-sm); line-height: 1.55; }
.birth-time-source-option > span { gap: 4px; }
.birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; }
.birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
.birth-time-period-details { align-items: start; }
.birth-time-period-details > label { align-self: start; }
@@ -1650,8 +1675,7 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.select-item-indicator { display: inline-flex; flex: 0 0 auto; color: var(--color-action); }
@keyframes select-content-in { from { opacity: 0; transform: translateY(-3px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.birth-time-source-option { grid-template-columns: minmax(0, 1fr); }
.birth-time-source-option input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.birth-time-source-option { grid-template-columns: 18px minmax(0, 1fr); }
.birth-time-source-option:focus-within { border-color: var(--color-action); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); }
.birth-time-source-option.is-selected:focus-within { box-shadow: inset 0 0 0 1px var(--color-action), 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); }
.birth-time-source-option > span { min-width: 0; }
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
@@ -9,6 +9,13 @@ export const metadata: Metadata = {
description: "与 Mastra Agent 对话,基于星盘证据讨论事业、关系与时间窗口。",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const enableReactDevTools = process.env.NODE_ENV === "development"
&& process.env.NEXT_PUBLIC_ENABLE_REACT_DEVTOOLS === "1";
+6 -2
View File
@@ -52,6 +52,7 @@ import {
describeBirthTimeDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
normalizePersistedBirthDate,
type BirthTimeDraft,
type BirthTimeSource,
@@ -1255,6 +1256,8 @@ export default function Home() {
&& !onboardingPending
&& !rectificationSurfaceOpen
&& !activeSession?.messages.length;
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const daypartGreeting = greetingForHour(new Date().getHours());
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
@@ -3257,7 +3260,7 @@ export default function Home() {
</header>
{!rectificationSurfaceOpen && (
<div ref={conversation} className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
<div ref={conversation} className={`conversation ${!activeSession?.messages.length && !onboardingFormActive ? "is-empty" : ""} ${onboardingFormActive ? "is-onboarding-form" : ""}`}>
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3287,6 +3290,7 @@ export default function Home() {
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions">
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
</div>
</fieldset>
@@ -3460,7 +3464,7 @@ export default function Home() {
/>
)}
{!rectificationSurfaceOpen && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
@@ -85,7 +85,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<section className="birth-time-profile-result" aria-label="生时校正结果">
<div className="birth-time-profile-result-heading">
<span></span>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "引擎确认"}</strong>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "已记录时间"}</strong>
</div>
<dl>
<div>
@@ -101,7 +101,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<p>{birthTimeConsultationOptionsCopy(value)}</p>
)}
{displayState.kind === "accepted" && (
<p>使</p>
<p>使</p>
)}
</section>
)}
+11 -2
View File
@@ -83,8 +83,8 @@ export function formatBirthDate(value: Date): string {
}
export const birthTimeSourceOptions = [
{ value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
{ value: "family_exact", label: "我知道准确出生时间", hint: "按医院记录或家人记得的时间填写,精确到分钟" },
{ value: "period_only", label: "我不确定准确时间", hint: "只知道大概几点、上午下午,或完全不清楚" },
] as const;
export const birthTimeSourceDefaults = {
@@ -223,6 +223,15 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
}
}
export function birthTimeDraftReadyHint(draft: BirthTimeDraft) {
if (!parseBirthDate(draft.date)) return "请先选择出生日期";
if (!draft.birthTimeSource) return "请选择你对出生时间了解多少";
if (isBirthTimeDraftReady(draft)) return "";
if (draft.birthTimeSource === "period_only") return "请选择一个大致时段";
if (draft.birthTimeSource === "unknown") return "";
return "请填写时和分";
}
/**
* Whether the user has finished declaring what they actually know about birth time.
* This is an onboarding condition, not a claim that an exact chart minute is ready.
@@ -117,11 +117,11 @@ test("model management renders localized labels without changing stored enum val
test("global native input sizing excludes Ant Design picker internals", () => {
assert.match(
globals,
/input:not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
);
assert.match(
globals,
/input:not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\):disabled/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\):disabled/,
);
assert.doesNotMatch(globals, /(?:^|\n)input, select \{ width: 100%; min-height: 44px; padding: 0 12px;/);
});
@@ -229,3 +229,15 @@ test("homepage and profile result copy use the source-aware consultation options
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(profile\)/);
assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/);
});
test("onboarding form steps hide the dead composer and keep source choices in a single column", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(page, /const onboardingFormActive = !profileComplete && onboardingStep !== "name"/);
assert.match(page, /!rectificationSurfaceOpen && !onboardingFormActive && <div className=\{`composer-wrap/);
assert.match(page, /birthTimeDraftReadyHint\(profileDraft\)/);
assert.match(css, /\.birth-time-source-list \{ grid-template-columns: 1fr/);
assert.doesNotMatch(css, /\.birth-time-source-list \{ grid-template-columns: repeat\(2/);
assert.doesNotMatch(css, /\.birth-time-source-option input \{[^}]*clip-path: inset\(50%\)/);
});
+26
View File
@@ -5,6 +5,7 @@ import {
applyBirthTimeDraftPatch,
assistantIntentCopy,
birthTimeDisplayState,
birthTimeDraftReadyHint,
birthTimePersistenceValues,
birthTimeSourceDefaults,
birthTimeSourceOptions,
@@ -308,9 +309,34 @@ test("persisted birth dates normalize database ISO values without accepting inva
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
});
test("fresh intake copy stays user-facing and explains the next missing field", () => {
assert.equal(
birthTimeSourceOptions.find((option) => option.value === "family_exact")?.hint,
"按医院记录或家人记得的时间填写,精确到分钟",
);
assert.equal(
birthTimeSourceOptions.find((option) => option.value === "period_only")?.hint,
"只知道大概几点、上午下午,或完全不清楚",
);
assert.equal(birthTimeSourceOptions.every((option) => !/引擎确认|初始化填报/.test(option.hint)), true);
assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, date: "" }), "请先选择出生日期");
assert.equal(birthTimeDraftReadyHint(emptyDraft), "请选择你对出生时间了解多少");
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
}), "请填写时和分");
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "period_only",
}), "请选择一个大致时段");
});
test("fresh intake preserves exact, approximate-period, and unknown-time paths without auto-confirming", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /引擎确认/);
assert.deepEqual(
birthTimeSourceOptions.map((option) => option.value),
["family_exact", "period_only"],
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
test("mobile login can scroll and keeps the form reachable on a short screen", () => {
const mobileAuth = css.indexOf(".auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto;");
assert.ok(mobileAuth >= 0, "the login page must be the mobile scroll container");
assert.match(css.slice(mobileAuth, mobileAuth + 900), /\.auth-shell \{ min-height: 100%; overflow: visible;/);
assert.match(css, /@media \(max-width: 767px\) and \(max-height: 640px\) \{\s*\.auth-story \{ display: none; \}/);
assert.match(layout, /interactiveWidget:\s*"resizes-content"/);
});
test("mobile onboarding does not clip location results or session history", () => {
assert.match(css, /\.session-nav \{ overflow: visible; \}/);
assert.doesNotMatch(css, /\.session-nav \{ overflow: hidden; \}/);
assert.match(css, /\.location-combobox-results \{ position: static; top: auto;/);
});