diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37d31503..232767d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
+## 2026-10-28 — 星盘页、星盘档案页打开更快
+
+- 星盘页第一次打开少下载约 116 KB:「改出生资料」弹窗等到点了那一行才加载(鼠标移上去就开始预取)。
+- 星盘档案页第一次打开少下载约 111 KB:「添加 / 编辑人物」的表单在页面出来后再在后台取,城市一列改读按需加载的城市表。
+- 页面内容和操作都不变。Skill 版本不变(BUG-1469,issue #120)。
+
## 2026-10-28 — 每个页面的侧栏会话都能「…」和拖动
- 在星盘、星历、我的报告、星盘档案页,侧栏的会话也有「…」菜单(收藏、重命名、转发、删除),也能拖动排序,跟首页一样。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 71228daa..a1f3381c 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -20033,3 +20033,19 @@
- 相关记录:BUG-1042~1047(该测试来源)、BUG-1467。
- 复发自:无
- 修复版本:分支 `codex/session-shell-116-20261027`。
+
+## BUG-1469 | 星盘页、星盘档案页首次加载静态带着全国城市表(BUG-1130 在次级页复发)
+
+- 状态:resolved(代码 + 回归测试;待部署,真机待产品)
+- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检 → #120)
+- 影响面:`/chart`、`/people` 首次加载;`frontend/src/components/chart-page/chart-page-view.tsx`、`frontend/src/components/people/people-page.tsx`、`frontend/src/lib/people-archive-view.ts`。
+- 现象:星盘页首次加载约 704 KB(gzip,本地构建),其中约 108 KB 是只有点「改出生资料」才用得到的弹窗、出生地选择器、全国城市表和日历;档案页约 663 KB,同样带着城市表。
+- 触发条件:冷开 `/chart` 或 `/people`。
+- 根因:星盘页静态引入 `ChartBirthEditor`(→ `chart-profile-form` → `profile-fields` → `birth-location-fields` → `birth-place-picker` → `china-birth-place` → `data/china-locations`);档案页静态引入 `ChartProfileForm`,城市一列的 `peopleCityLabel` 又直接用 `china-birth-place` 查表。BUG-1130 只把首页首屏里的城市表挪走,并有意让档案页继续静态带表;防复发只覆盖首页首屏,所以次级页的静态路径没人拦。
+- 修复:星盘页的编辑弹窗改为点击时加载(`next/dynamic`,无占位),鼠标移上或聚焦那一行时预取;档案页的添加 / 编辑表单改为页面挂载后在后台取;`peopleCityLabel` 改读按需加载的表(`home-profile` 的 `findProvince` / `findCity`):只有编码没有已存地点的老资料,冷启动在人物列表落定前已把表载入(`current-subject.ts` `loadPlacesFor`),其余资料用已存地点名里的城市段,名字相同。
+- 验证:本地 `next build`(gzip-9,含 noModule 补丁,前后同口径):`/chart` 703,510 → 587,183 B(−116 KB),`/people` 662,898 → 551,646 B(−111 KB),首页 702,985 → 704,289 B(+0.19%,仍 `○ Static`)。新测试 `frontend/tests/secondary-first-load-20261028.test.ts`:从两个页面沿静态 import 图走,走不到城市表(在改动前的代码上失败并打印完整引用链);城市列在表未载入时用已存地点名、载入后与查表结果一致。
+- 防复发:上面的静态 import 图测试覆盖两个次级页;只在少数操作里用到的大模块(编辑弹窗、地点选择、城市表)不进任何页面的首次加载,首页之外的页面同样适用。
+- 相关记录:BUG-1130(首页首屏版本)、BUG-1127~1129。
+- 复发自:BUG-1130(防复发只覆盖首页首屏)
+- 修复版本:分支 `codex/chart-lazy-editor-20261028`。
+
diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md
new file mode 100644
index 00000000..8116371e
--- /dev/null
+++ b/docs/testing/code-health-118-20261028.md
@@ -0,0 +1,10 @@
+# 代码体检小单真机清单(issue #118,#120~#139)
+
+每张小单部署到 staging 后在这里加几条。需要 staging 测试账号。
+
+## #120 星盘页、档案页打开更快(BUG-1469)
+
+1. 电脑打开星盘页:盘、表、右栏照常出来。点盘上方那行「日期 时间 · 地点」:弹出编辑框,能改能存,存完盘按新资料重排。
+2. 手机打开星盘页,点那一行:同样能弹出编辑框(可能比以前慢一点点,但不出现转圈)。
+3. 打开星盘档案页:每个人那一行的「日期 · 时间 · 城市」和以前一样;老资料(只选过省市、很早建的)城市也照常显示。
+4. 档案页点「添加一个人」、点某人的「编辑」:表单照常弹出,出生地能选、能存。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 818143cf..9f33902d 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -412,7 +412,7 @@ The birth-time rectification session is the consultation transcript plus a house
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
-- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it.
+- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. The editor's code is not in `/chart`'s first load: it loads on the click and is warmed when the pen row is hovered or focused, and nothing is drawn while it arrives (no placeholder; BUG-1469). The people page's add / edit form likewise stays out of `/people`'s first load and is fetched right after the page mounts.
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit control (`.credit-balance`) is a normal outline button, 44px, with a coins icon and the number; 点 stays in the accessible name. The rectification 「当前盘面」 chip stays 40px. The shared `::before` hit area stays inset -2px. The onboarding checklist trigger stays 32px.
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx
index b29d48fb..27f30e8e 100644
--- a/frontend/src/components/chart-page/chart-page-view.tsx
+++ b/frontend/src/components/chart-page/chart-page-view.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Pencil } from "lucide-react";
@@ -21,7 +22,6 @@ import {
ChartTags,
} from "@/components/chart-board/chart-side-cards";
import { ChartTypeTable } from "@/components/chart-board/chart-type-table";
-import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
import { useChartBoard } from "@/hooks/use-chart-board";
@@ -47,6 +47,14 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { ChartPageSkeleton } from "./chart-page-skeleton";
import { ChartWesternTab } from "./chart-western-tab";
+// The pencil is the only way into the birth editor, so its code (profile form,
+// place picker with the China city table, calendar) loads on that click and is
+// warmed when the pencil is hovered or focused. It used to ride in /chart's
+// first load (BUG-1469, the secondary-page return of BUG-1130).
+const loadChartBirthEditor = () => import("@/components/chart-page/chart-birth-editor")
+ .then((module) => module.ChartBirthEditor);
+const ChartBirthEditor = dynamic(loadChartBirthEditor, { ssr: false, loading: () => null });
+
export function ChartPageRoute() {
const { view, pendingLayers, layerFailures, requestLayer, reload } = useChartPage();
const ok = view?.status === "ok" ? view : null;
@@ -277,6 +285,8 @@ export function ChartPageView({
variant="ghost"
className="chart-birth-line h-auto rounded-none border-0 bg-transparent px-0 font-normal shadow-none"
aria-label={`编辑出生资料,${view.profile.date} ${view.profile.time},${view.profile.placeLabel}`}
+ onPointerEnter={() => { void loadChartBirthEditor(); }}
+ onFocus={() => { void loadChartBirthEditor(); }}
onClick={() => setEditingBirth(true)}
>
{`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}
diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx
index d7d55159..4132d124 100644
--- a/frontend/src/components/people/people-page.tsx
+++ b/frontend/src/components/people/people-page.tsx
@@ -2,10 +2,10 @@
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type ReactNode, type SetStateAction } from "react";
import { ChevronLeft, ChevronRight, FileText, MessageSquareText, MoreHorizontal, Orbit, Plus } from "lucide-react";
+import dynamic from "next/dynamic";
import { newChatHref } from "@/lib/chat-session-url";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
-import { ChartProfileForm } from "@/components/chart-profile-form";
import { personRelationLabel } from "@/lib/person-relation";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
@@ -60,6 +60,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+// The add / edit form carries the place picker and with it the China city
+// table. It is not needed to read the archive, so it stays out of /people's
+// first load and is fetched right after the page mounts (BUG-1469).
+const loadChartProfileForm = () => import("@/components/chart-profile-form").then((module) => module.ChartProfileForm);
+const ChartProfileForm = dynamic(loadChartProfileForm, { ssr: false, loading: () => null });
+
export type PersonRow = {
readonly id: string;
readonly role: "self" | "other";
@@ -119,6 +125,8 @@ export function PeoplePage() {
const accountIdRef = useRef("");
const { setAccount } = useSessionList();
+ useEffect(() => { void loadChartProfileForm(); }, []);
+
async function load() {
setStatus("loading");
try {
diff --git a/frontend/src/lib/people-archive-view.ts b/frontend/src/lib/people-archive-view.ts
index 59c86afd..c0dbced8 100644
--- a/frontend/src/lib/people-archive-view.ts
+++ b/frontend/src/lib/people-archive-view.ts
@@ -5,8 +5,7 @@
*/
import { AYANAMSA_OPTIONS, resolveAyanamsa } from "@/lib/ayanamsa";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
-import { findCityNode, findProvinceNode } from "@/lib/china-birth-place";
-import { profileBirthTimeLabel } from "@/lib/home-profile";
+import { findCity, findProvince, profileBirthTimeLabel } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
/** D4: four existing element tokens; the person's id picks one, stably. */
@@ -40,8 +39,11 @@ function trimCitySuffix(name: string): string {
* of a 「中国 · 省 · 市 · 区」 label. Empty when the place is not complete.
*/
export function peopleCityLabel(profile: Profile): string {
- // The people page carries the table itself (china-birth-place imports it statically).
- const city = findCityNode(findProvinceNode(profile.provinceCode), profile.cityCode);
+ // Reads the on-demand table (BUG-1469). A profile that has codes but no saved
+ // label + coordinates has the table loaded before the people list settles
+ // (current-subject.ts loadPlacesFor); every other profile carries its label,
+ // whose city segment is the same name.
+ const city = findCity(findProvince(profile.provinceCode), profile.cityCode);
if (city) return trimCitySuffix(city.name);
const label = profile.birthPlaceLabel.trim();
if (!label) return "";
diff --git a/frontend/tests/secondary-first-load-20261028.test.ts b/frontend/tests/secondary-first-load-20261028.test.ts
new file mode 100644
index 00000000..9d742333
--- /dev/null
+++ b/frontend/tests/secondary-first-load-20261028.test.ts
@@ -0,0 +1,89 @@
+import assert from "node:assert/strict";
+import { existsSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { emptyProfile } from "../src/lib/home-types.ts";
+import { ensureChinaLocations, resetChinaLocationsForTests } from "../src/lib/china-locations-client.ts";
+import { peopleCityLabel } from "../src/lib/people-archive-view.ts";
+
+// BUG-1469 (issue #120): BUG-1130 took the China city table out of the home
+// first screen, but /chart still loaded it statically through the birth
+// editor and /people through the add / edit form and the city column.
+// These tests walk the static import graph, so a new static path back to the
+// table fails here, whichever file it goes through.
+
+const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src");
+const extensions = [".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
+
+function resolveImport(from: string, specifier: string): string | null {
+ let base: string;
+ if (specifier.startsWith("@/")) base = path.join(src, specifier.slice(2));
+ else if (specifier.startsWith(".")) base = path.resolve(path.dirname(from), specifier);
+ else return null;
+ if (existsSync(base) && statSync(base).isFile()) return base;
+ for (const extension of extensions) {
+ if (existsSync(base + extension)) return base + extension;
+ }
+ return null;
+}
+
+/** Value imports and re-exports only: `import type`, `export type` and `import()` do not load code. */
+function staticImports(file: string): string[] {
+ if (file.endsWith(".json")) return [];
+ const text = readFileSync(file, "utf8");
+ const found: string[] = [];
+ const pattern = /^\s*(?:import|export)\s+(?!type\b)(?:[^;'"]*?\sfrom\s+)?["']([^"']+)["']/gm;
+ for (const match of text.matchAll(pattern)) {
+ const resolved = resolveImport(file, match[1]);
+ if (resolved) found.push(resolved);
+ }
+ return found;
+}
+
+function staticallyReaches(entry: string, target: RegExp): string[] | null {
+ const seen = new Map([[entry, null]]);
+ const queue = [entry];
+ while (queue.length > 0) {
+ const file = queue.shift()!;
+ if (target.test(file)) {
+ const chain: string[] = [];
+ for (let at: string | null = file; at; at = seen.get(at) ?? null) chain.unshift(path.relative(src, at));
+ return chain;
+ }
+ for (const next of staticImports(file)) {
+ if (seen.has(next)) continue;
+ seen.set(next, file);
+ queue.push(next);
+ }
+ }
+ return null;
+}
+
+const cityTable = /[\\/]data[\\/]china-locations(\.json|\.ts)?$/;
+
+for (const entry of ["components/chart-page/chart-page-view.tsx", "components/people/people-page.tsx"]) {
+ test(`${entry} does not load the China city table statically (BUG-1469)`, () => {
+ const chain = staticallyReaches(path.join(src, entry), cityTable);
+ assert.equal(chain, null, chain ? `static path: ${chain.join(" → ")}` : "");
+ });
+}
+
+test("the walker does see the table where it is still static, so the check above is not vacuous", () => {
+ assert.ok(staticallyReaches(path.join(src, "components/birth-place-picker.tsx"), cityTable));
+});
+
+test("the people city column reads the on-demand table and falls back to the saved label (BUG-1469)", async () => {
+ resetChinaLocationsForTests();
+ // Codes plus the label the picker saves: the label's city segment, same name as the table's.
+ const saved = { ...emptyProfile, provinceCode: "130000", cityCode: "130100", birthPlaceLabel: "中国 · 河北省 · 石家庄市 · 长安区" };
+ assert.equal(peopleCityLabel(saved), "石家庄");
+ // Codes only (an old profile): blank until the table is loaded, which the cold boot does before the list settles.
+ const codesOnly = { ...emptyProfile, provinceCode: "130000", cityCode: "130100" };
+ assert.equal(peopleCityLabel(codesOnly), "");
+ await ensureChinaLocations();
+ assert.equal(peopleCityLabel(codesOnly), "石家庄");
+ assert.equal(peopleCityLabel(saved), "石家庄");
+ resetChinaLocationsForTests();
+});