diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index d4dba4da..bb87d7a3 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -110,6 +110,17 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
- **Guided responsive/accessibility contract:** body copy remains at least 14px, labels at least 12px, and all controls at least 44px. Focus is always visible, the composer is keyboard operable, semantic Chinese phrases remain together at 390px, and reduced-motion removes entrance translation while retaining state changes.
+### Birth place picker
+
+- **Composition:** three cascading Base UI Selects — province, city, district — over the bundled China administrative dataset. There is no free-text search and no overseas provider in this surface.
+- **Level collapsing:** a level that offers no choice is not rendered. Municipalities and special administrative regions skip the repeated city level; prefecture cities without districts complete at the city; a province with neither lower level completes at the province.
+- **Unsure escape hatch:** any city that has districts leads its district list with “不确定,用市区中心”, so browsing never dead-ends. A stored empty district resumes as that choice.
+- **Precision copy:** the picker states that county-level precision is enough for a chart, so a village or township birth is not treated as missing data.
+- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke.
+- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place.
+- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again.
+- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region.
+
### Birth date picker
- **Composition:** shadcn outline Button trigger, Base UI Popover, and a single-select React DayPicker Calendar.
diff --git a/frontend/src/app/api/locations/timezone/route.ts b/frontend/src/app/api/locations/timezone/route.ts
new file mode 100644
index 00000000..b9d0b6de
--- /dev/null
+++ b/frontend/src/app/api/locations/timezone/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server";
+import { resolveBirthLocationTimezone } from "@/lib/birth-location-timezone-service";
+import { birthLocationTimezoneQuerySchema } from "@/lib/location-contract";
+import { createServerSupabaseClient } from "@/lib/supabase/server";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request) {
+ const supabase = await createServerSupabaseClient();
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+ if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: "时区解析参数不正确" }, { status: 400 });
+ }
+
+ const parsed = birthLocationTimezoneQuerySchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({
+ error: "时区解析参数不正确",
+ details: parsed.error.flatten(),
+ }, { status: 400 });
+
+ const result = await resolveBirthLocationTimezone(parsed.data);
+ return NextResponse.json(result, { status: result.status === "ok" ? 200 : 503 });
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 95201891..c2b234e1 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -237,31 +237,20 @@ button:disabled { cursor: default; opacity: .45; }
.profile-grid, .location-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.location-fieldset { margin: 0; padding: 0; border: 0; }
.location-fieldset legend { margin-bottom: 10px; color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
-.location-combobox { position: relative; display: grid; gap: 7px; }
-.location-combobox > label { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
-.location-combobox-input-wrap { position: relative; }
-.location-combobox-input-wrap input { width: 100%; min-height: 46px; padding-left: 42px; padding-right: 42px; }
-.location-combobox.is-selected .location-combobox-input-wrap input { border-color: color-mix(in srgb, var(--color-success) 42%, var(--color-border)); background: color-mix(in srgb, var(--color-success-muted) 42%, var(--color-canvas)); }
-.location-combobox-leading { width: 17px; height: 17px; position: absolute; z-index: 1; left: 14px; top: 50%; color: var(--color-ink-tertiary); pointer-events: none; transform: translateY(-50%); }
-.location-combobox-leading.is-spinning { animation: location-combobox-spin .8s linear infinite; }
-.location-combobox-clear { width: 32px; height: 32px; position: absolute; right: 7px; top: 50%; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--color-ink-secondary); cursor: pointer; transform: translateY(-50%); }
-.location-combobox-clear:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
-.location-combobox-clear svg { width: 16px; height: 16px; }
-.location-combobox-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; }
-.location-combobox.is-selected .location-combobox-status { color: var(--color-success); }
-.location-combobox-status.is-error { color: var(--color-danger); }
-.location-combobox-status svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; }
-.location-combobox-results { width: 100%; max-height: 280px; position: absolute; z-index: 70; top: calc(100% - 16px); left: 0; overflow-y: auto; display: grid; gap: 2px; margin: 0; padding: 5px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); list-style: none; }
-.location-combobox-results li { margin: 0; }
-.location-combobox-results li > button { width: 100%; min-height: 50px; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; }
-.location-combobox-results li > button > svg { width: 17px; height: 17px; color: var(--color-ink-tertiary); }
-.location-combobox-results li > button > span { min-width: 0; display: grid; gap: 2px; }
-.location-combobox-results li > button b { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
-.location-combobox-results li > button small { overflow: hidden; color: var(--color-ink-tertiary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
-.location-combobox-results li.is-active > button, .location-combobox-results li > button:hover { background: var(--color-action-soft); color: var(--color-action-hover); }
-.location-combobox-feedback { padding: 12px; color: var(--color-ink-secondary); font-size: 12px; line-height: 1.5; }
-.location-combobox-feedback.is-error { color: var(--color-danger); }
-@keyframes location-combobox-spin { to { transform: translateY(-50%) rotate(360deg); } }
+.birth-place-picker { display: grid; gap: 8px; }
+.birth-place-levels { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
+.birth-place-levels > label { min-width: 0; display: grid; gap: 7px; }
+.birth-place-levels > label > span { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
+.birth-place-note { margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; text-wrap: pretty; word-break: auto-phrase; }
+.birth-place-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; }
+.birth-place-status.is-ready { color: var(--color-success); }
+.birth-place-status.is-error { color: var(--color-danger); }
+.birth-place-status > svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; }
+.birth-place-status > span { min-width: 0; text-wrap: pretty; word-break: auto-phrase; }
+.birth-place-status .is-spinning { animation: birth-place-spin .8s linear infinite; }
+.birth-place-retry { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; border: 0; background: transparent; color: var(--color-action); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
+.birth-place-retry svg { width: 12px; height: 12px; }
+@keyframes birth-place-spin { to { transform: rotate(360deg); } }
.save-profile { justify-self: end; }
.form-error { background: var(--color-danger-muted); color: var(--color-danger); }
.form-success { background: var(--color-success-muted); color: var(--color-success); }
@@ -958,7 +947,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.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; }
+ .select-content { max-height: min(320px, 56dvh); }
+ .select-list { max-height: min(288px, 48dvh) !important; }
.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); }
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 1995a499..f6f4841e 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -28,10 +28,7 @@ import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
-import {
- LocationSearchCombobox,
- type ResolvedBirthLocation,
-} from "@/components/location-search-combobox";
+import { BirthPlacePicker } from "@/components/birth-place-picker";
import { ChatComposer } from "@/components/chat-composer";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
@@ -390,28 +387,6 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
};
}
-function selectedLocationValue(profile: Profile): ResolvedBirthLocation | null {
- const birthPlace = selectedBirthPlace(profile);
- if (!birthPlace) return null;
- return {
- id: profile.birthPlaceProviderId || `legacy-cn:${profile.provinceCode}:${profile.cityCode}:${profile.districtCode}`,
- label: birthPlace.label,
- placeType: profile.birthPlaceType || "legacy_china_admin",
- provider: profile.birthPlaceProvider || "legacy_china_locations",
- providerPlaceId: profile.birthPlaceProviderId,
- countryCode: profile.countryCode || "CN",
- countryName: profile.countryCode === "CN" ? "中国" : "",
- admin1: "",
- admin2: "",
- locality: "",
- latitude: birthPlace.lat,
- longitude: birthPlace.lon,
- timezoneId: birthPlace.timezoneId,
- timezoneOffset: birthPlace.tz,
- timezoneSource: profile.timezoneSource || "legacy_fixed_offset",
- };
-}
-
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
@@ -761,22 +736,28 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p
return (