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)); +});