From bced1b9d57cf2f844568041979c3c98fa9b1d208 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 11:41:35 +0800 Subject: [PATCH] fix(ui): keep mobile onboarding and login usable 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. --- frontend/src/app/globals.css | 66 +++++++++++++------ frontend/src/app/layout.tsx | 9 ++- frontend/src/app/page.tsx | 8 ++- frontend/src/components/birth-time-intake.tsx | 4 +- frontend/src/lib/birth-time-intake-model.ts | 13 +++- ...admin-model-management-ui-contract.test.ts | 4 +- .../birth-time-consultation-consent.test.ts | 12 ++++ frontend/tests/birth-time-intake.test.ts | 26 ++++++++ .../tests/mobile-interaction-contract.test.ts | 20 ++++++ 9 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 frontend/tests/mobile-interaction-contract.test.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 12c1e68e..e606e9d1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 4ff46db7..acec6471 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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"; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 95e858e0..bd19c400 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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() { {!rectificationSurfaceOpen && ( -
+
{!activeSession?.messages.length ? (
{!profileComplete ? ( @@ -3287,6 +3290,7 @@ export default function Home() { setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> {accountError &&

{accountError}

}
+ {birthTimeContinueHint ?

{birthTimeContinueHint}

: }
@@ -3460,7 +3464,7 @@ export default function Home() { /> )} - {!rectificationSurfaceOpen &&
+ {!rectificationSurfaceOpen && !onboardingFormActive &&
{activeSuggestions.length > 0 && (
{activeSuggestions.map((question) => ( diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 4793b8fc..fb015225 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -85,7 +85,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
出生时间记录 - {displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "引擎确认"} + {displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "已记录时间"}
@@ -101,7 +101,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)

这仍是未确认候选,不会自动成为出生分钟;{birthTimeConsultationOptionsCopy(value)}

)} {displayState.kind === "accepted" && ( -

这是你从候选中选择的校正采用时间,并非引擎唯一确认分钟;后续排盘会使用它。

+

这是你从候选中选择的校正采用时间,不是外部档案确认的出生时间;后续排盘会使用它。

)}
)} diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index e1716736..9abf79db 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -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. diff --git a/frontend/tests/admin-model-management-ui-contract.test.ts b/frontend/tests/admin-model-management-ui-contract.test.ts index 488a7782..0e6ee46d 100644 --- a/frontend/tests/admin-model-management-ui-contract.test.ts +++ b/frontend/tests/admin-model-management-ui-contract.test.ts @@ -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;/); }); diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index a2a3ef5e..58e48589 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -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 &&
{ + 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"], diff --git a/frontend/tests/mobile-interaction-contract.test.ts b/frontend/tests/mobile-interaction-contract.test.ts new file mode 100644 index 00000000..6f90e01f --- /dev/null +++ b/frontend/tests/mobile-interaction-contract.test.ts @@ -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;/); +});