diff --git a/CHANGELOG.md b/CHANGELOG.md
index 663d2c5e..13764736 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
## 2026-11-03 — 常用问题:文字入口和两栏弹窗(issue #151 改版,BUG-1534)
- 入口:输入框左下角的红色圆底图标换成一个 32px 高的细边胶囊,左边一颗主题色小星,右边写「常用问题」;无底色,悬停浅灰底,可点区域 44px 高。桌面和手机同一个样子。
-- 桌面弹窗:固定 600×432,左边 168px 一列列出全部 12 个分类,右边直接是当前分类的 5 个问题,中间一条细线。鼠标停在分类上就换右边的问题,不用点;上下键换分类,右键或 Tab 进问题,回车选中。打开时停在上一次选过问题的分类(只记在这一页里)。去掉「›」箭头、衬线粗体标题和返回按钮。「压力与生活节奏」的安全提示照旧显示在问题上方。
+- 桌面弹窗:固定 600×404,只在入口上方或下方弹出,不会跑到入口右边盖住输入框,左边 168px 一列列出全部 12 个分类,右边直接是当前分类的 5 个问题,中间一条细线。鼠标停在分类上就换右边的问题,不用点;上下键换分类,右键或 Tab 进问题,回车选中。打开时停在上一次选过问题的分类(只记在这一页里)。去掉「›」箭头、衬线粗体标题和返回按钮。「压力与生活节奏」的安全提示照旧显示在问题上方。
- 手机:底部弹出固定高度,标题「常用问题」,下面一排 12 个分类胶囊可以左右拖,当前分类浅主题色;再下面是 5 个问题,每行 52px、细线分隔。
- 修正(BUG-1534):分类列表里面不再上下滚动;鼠标移动时不会两行同时变色,同一时刻只有一行问题有浅底。
- 选中问题后仍是填进输入框、弹窗关闭,不会直接发出。问题文字没有改。没有数据库变更。Skill 版本不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 848dd158..69cd0b10 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -21008,8 +21008,8 @@
- 根因:
1. 菜单借用了「选择星盘」的列表样式 `.chat-profile-picker-options`(`max-height: min(50vh, 320px); overflow-y: auto`),12 行 × 48px 超出 320px,只能在里面滚。
2. 行的底色只靠 `:hover`,而且有两层背景渐变:自己的 `transition: background-color 120ms` 和 kit `Button` 的 `duration-150`。鼠标离开的那一行还在褪色,新进入的一行已经上色,两行同时有底。
-- 修复:随产品确认的「A + 1」改版一起改。桌面换成固定 600×432 的两栏(168px 分类 + 问题),手机换成固定高度的底部弹出(分类胶囊一排 + 5 个问题),两处都不再有纵向滚动,高度按最高的一组(「压力与生活节奏」带两三行提示)算好。问题行不再用 `:hover` 上色,改成 `.is-active` 一个状态(最后一次指针进入或获得焦点的那一行),并去掉背景渐变(`transition: none`),同一时刻只有一行有底。
-- 验证:新增 `frontend/tests/consult-preset-menu-20261103.test.tsx` 七条:入口是文字胶囊、可点区域按 CSS 算不小于 44px;桌面 12 个分类加 5 个问题、所有 `.consult-preset-*` 规则里没有 `overflow(-y): auto|scroll`、12 行和最高一组问题都放得进 430px;悬停(80ms)或方向键换分类、斜着划过不换;同一时刻只有一行 `is-active`,没有 `.consult-preset-question:hover` 规则;选中后 `onPick` 收到该分类的原文并关闭,下次打开停在这个分类;手机有标题、12 个胶囊和 5 个问题,面板固定高度放得下最高一组;最长标题在 360px 和桌面都一行放得下。本地 `next build --webpack` + `next start` + 拦截接口的虚构资料截图(1280 / 390,浅色 / 深色),不是登录后的真页面。
+- 修复:随产品确认的「A + 1」改版一起改。桌面换成固定 600×404 的两栏(168px 分类 + 问题),手机换成固定高度的底部弹出(分类胶囊一排 + 5 个问题),两处都不再有纵向滚动,高度按最高的一组(「压力与生活节奏」带两三行提示)算好。问题行不再用 `:hover` 上色,改成 `.is-active` 一个状态(最后一次指针进入或获得焦点的那一行),并去掉背景渐变(`transition: none`),同一时刻只有一行有底。
+- 验证:新增 `frontend/tests/consult-preset-menu-20261103.test.tsx` 八条:入口是文字胶囊、可点区域按 CSS 算不小于 44px;桌面 12 个分类加 5 个问题、所有 `.consult-preset-*` 规则里没有 `overflow(-y): auto|scroll`、12 行和最高一组问题都放得进 430px;悬停(80ms)或方向键换分类、斜着划过不换;同一时刻只有一行 `is-active`,没有 `.consult-preset-question:hover` 规则;选中后 `onPick` 收到该分类的原文并关闭,下次打开停在这个分类;手机有标题、12 个胶囊和 5 个问题,面板固定高度放得下最高一组;最长标题在 360px 和桌面都一行放得下;弹窗只在入口上下翻转、不退到侧面(`fallbackAxisSide: "none"`),高度不超过 408px。改版第一稿是 432px 高、用 kit 默认的碰撞处理,空白新对话(1280×860)里上下都放不下,弹窗跑到入口右侧盖住输入框,产品验收前已在同一分支改掉。本地 `next build --webpack` + `next start` + 拦截接口的虚构资料截图(1280 / 390,浅色 / 深色),不是登录后的真页面。
- 防复发:上面的测试。规则:弹出层里的列表要按内容算好固定尺寸,不要借用另一个组件带 `max-height` 和滚动的列表类;可悬停的行不要同时用 `:hover` 底色和背景渐变,用一个状态类决定哪一行有底。
- 关联记录:issue #151(常用问题菜单)。
- 复发自:无
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 83b5ad22..7773150e 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -303,7 +303,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. The `toolbar` slot held only the model picker and left with it (2026-11-02); neither surface puts a control in the box's bottom row besides send / stop and the count. The only thing under the box is the one-line AI notice, not another control.
- **Preset questions 「常用问题」 (issue #151; redesigned 2026-11-03, product 「A + 1」, BUG-1534):** the one control at the left of the composer row (`leadingSlot` → `.composer-row-start`), in the ordinary chat only (not synastry, not rectification). Everything else in the row is as described above.
- **Trigger:** a text pill — a 14px `Sparkle` in `--color-action` and 「常用问题」 at 13px `--color-ink-secondary`; 32px tall, `padding: 0 12px`, `1px --color-border`, radius 999px, transparent; hover and open take `--color-canvas-muted`; a `::before` stretches the target to 44px tall. Same pill on desktop and phone. It replaced a 36px `--color-action-soft` disc with an icon only, which read as an unexplained red dot.
- - **Desktop (`> 640px`):** a Popover (side top, align start) fixed at 600×432, two columns: a 168px domain nav and the question pane, split by one `--color-border` hairline. The nav lists all twelve domains as kit vertical Tabs, rows 32px / radius 8 / 13px; the current domain is `.is-current` (`--color-action-soft`, `--color-ink`), the rest `--color-ink-secondary`. Resting the pointer on a domain for 80ms (`CONSULT_PRESET_HOVER_MS`) switches the pane, so a diagonal pass on the way to the questions does not; Up / Down move and select, Right / Enter / Tab go into the questions, Up / Down move there, Left comes back. The pane is a 12px `--color-ink-tertiary` domain label, the stress group's fixed safety note when it has one (two lines at this width), and five question rows (min 52px, 14px `--color-ink`, radius 10). The title is visually hidden; no chevrons, no back button.
+ - **Desktop (`> 640px`):** a Popover (side top, align start) fixed at 600×404, two columns: a 168px domain nav and the question pane, split by one `--color-border` hairline. The panel only flips between above and below the trigger (`collisionAvoidance` `{ side: "flip", align: "shift", fallbackAxisSide: "none" }`, `collisionPadding` 8): in a blank new chat the composer sits mid screen, and a side fallback put the panel at the trigger's right, over the input. 404px keeps it above the trigger on an 860px-tall window. The nav lists all twelve domains as kit vertical Tabs, rows 32px with no gap (12 × 32 + 16 = 400) / radius 8 / 13px; the current domain is `.is-current` (`--color-action-soft`, `--color-ink`), the rest `--color-ink-secondary`. Resting the pointer on a domain for 80ms (`CONSULT_PRESET_HOVER_MS`) switches the pane, so a diagonal pass on the way to the questions does not; Up / Down move and select, Right / Enter / Tab go into the questions, Up / Down move there, Left comes back. The pane is a 12px `--color-ink-tertiary` domain label, the stress group's fixed safety note when it has one (two lines at this width), and five question rows (min 52px, 14px `--color-ink`, radius 10). The title is visually hidden; no chevrons, no back button.
- **Phone (`≤ 640px`):** the Drawer, fixed height: a 15px/600 title 「常用问题」, a sideways-dragged row of twelve 36px chips (`.is-current` in `--color-action-soft`), then the current domain's five 52px rows with hairlines between. The pane is a fixed 344px, sized for the tallest domain (the stress note at three lines on a 320px screen plus five rows), so switching chips never changes the sheet's height.
- **No inner vertical scroll and one tint:** nothing in either surface scrolls vertically. Only one question row is tinted at a time: `.is-active` (`--color-canvas-muted`) follows the last pointer-enter or focus, there is no `:hover` background and no background fade, so two rows are never shaded together (BUG-1534).
- **Pick:** fills the composer exactly as before (`onPick`; the page selects the first 「(…)」 blank) and closes; nothing is sent. The menu opens next time on the domain the last pick came from (module memory, gone on reload). The prompt text is the astrologer-reviewed wording in `consult-preset-prompts.ts`; this component never edits it.
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index d604db86..ca6a0ba1 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1984,12 +1984,12 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
.consult-preset-trigger:hover:not(:disabled), .consult-preset-trigger[data-popup-open] { background: var(--color-canvas-muted); }
.consult-preset-trigger:disabled { cursor: default; opacity: 0.5; }
.consult-preset-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
-.consult-preset-popup { width: 600px; height: 432px; max-width: calc(100vw - var(--space-6)); gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
+.consult-preset-popup { width: 600px; height: 404px; max-width: calc(100vw - var(--space-6)); gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
.consult-preset-popup[data-starting-style], .consult-preset-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); }
@media (prefers-reduced-motion: reduce) { .consult-preset-popup[data-starting-style], .consult-preset-popup[data-ending-style] { transform: none; } }
.consult-preset-panel { height: 100%; min-height: 0; display: grid; gap: 0; grid-template-columns: 168px minmax(0, 1fr); }
-/* 12 rows x 32px + 11 x 2px gaps + 16px padding = 422px of the 430px inside the frame. */
-.consult-preset-nav { width: auto; min-height: 0; display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 8px; overflow: hidden; border-right: 1px solid var(--color-border); }
+/* 12 rows x 32px + 16px padding = 400px of the 402px inside the frame. */
+.consult-preset-nav { width: auto; min-height: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 8px; overflow: hidden; border-right: 1px solid var(--color-border); }
.consult-preset-domain { height: 32px; flex: none; display: flex; align-items: center; justify-content: flex-start; margin: 0; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-ink-secondary); cursor: pointer; font: inherit; font-size: 13px; line-height: 1.2; text-align: left; white-space: nowrap; }
.consult-preset-domain:hover { color: var(--color-ink); }
.consult-preset-domain.is-current { background: var(--color-action-soft); color: var(--color-ink); }
diff --git a/frontend/src/components/consult-preset-menu.tsx b/frontend/src/components/consult-preset-menu.tsx
index b16f3c6d..dd5e2153 100644
--- a/frontend/src/components/consult-preset-menu.tsx
+++ b/frontend/src/components/consult-preset-menu.tsx
@@ -33,6 +33,13 @@ export const CONSULT_PRESET_MENU_LABEL = "常用问题";
*/
export const CONSULT_PRESET_HOVER_MS = 80;
+/**
+ * Above or below the trigger only. In a blank new chat the composer sits mid
+ * screen; the kit default then fell back to the trigger's right and covered
+ * the input. With no side fallback the panel stays on the top/bottom axis.
+ */
+export const CONSULT_PRESET_COLLISION = { side: "flip", align: "shift", fallbackAxisSide: "none" } as const;
+
// The domain the last question was picked from. Memory only: a reload starts
// at the first domain again.
let lastDomainIndex = 0;
@@ -221,6 +228,8 @@ export function ConsultPresetMenu({ disabled = false, onPick }: ConsultPresetMen
side="top"
align="start"
sideOffset={8}
+ collisionAvoidance={CONSULT_PRESET_COLLISION}
+ collisionPadding={8}
initialFocus={currentDomainRef}
>
{CONSULT_PRESET_MENU_LABEL}
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx
index a901cb1f..cee4b0c4 100644
--- a/frontend/src/components/ui/popover.tsx
+++ b/frontend/src/components/ui/popover.tsx
@@ -20,11 +20,13 @@ function PopoverContent({
side = "bottom",
sideOffset = 4,
anchor,
+ collisionAvoidance,
+ collisionPadding,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset" | "anchor"
+ "align" | "alignOffset" | "side" | "sideOffset" | "anchor" | "collisionAvoidance" | "collisionPadding"
>) {
return (
@@ -34,6 +36,8 @@ function PopoverContent({
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
+ collisionAvoidance={collisionAvoidance}
+ collisionPadding={collisionPadding}
className="isolate z-50"
>
{
+ // In a blank new chat the composer sits mid screen; a side fallback put the
+ // panel at the trigger's right, over the input.
+ assert.deepEqual(CONSULT_PRESET_COLLISION, { side: "flip", align: "shift", fallbackAxisSide: "none" });
+ assert.match(source, /side="top"\s+align="start"\s+sideOffset=\{8\}\s+collisionAvoidance=\{CONSULT_PRESET_COLLISION\}\s+collisionPadding=\{8\}/);
+ // The kit popover hands both props to the positioner, not to the popup element.
+ const kit = readFileSync(new URL("../src/components/ui/popover.tsx", import.meta.url), "utf8");
+ assert.match(kit, /collisionAvoidance=\{collisionAvoidance\}\s+collisionPadding=\{collisionPadding\}/);
+ assert.equal(rule(".consult-preset-nav").gap, "0");
+ // 12 x 32 + padding, plus the 1px frame, within the 408px limit.
+ assert.ok(12 * 32 + 16 + 2 <= px(rule(".consult-preset-popup").height));
+});