fix(rectification): keep the choice selected state inside the button
Independent Staging Quality Gate / validate (push) Successful in 10m25s
Independent Staging Quality Gate / publish (push) Successful in 1m57s

The outer focus ring overflowed the message bubble and stacked on the existing selected border. Draw keyboard focus inset and skip auto-focusing the first option.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 21:44:50 +08:00
parent 7b82d43c82
commit 3568801509
5 changed files with 48 additions and 10 deletions
+16
View File
@@ -7617,3 +7617,19 @@
- 复发自:无
- 修复版本:待发布
## BUG-495 | 选择题选中项外圈粗描边溢出气泡
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`.birth-time-choice-option:focus-visible`、嵌入 `RectificationChoiceCard``.message-bubble`
- 用户现象:点选 A 后(或新题自动聚焦第一项时),选项外侧出现一圈粗的红棕色描边,圆角对不齐,看起来像边框溢出。已有选中底色和边框已经够用。
- 触发条件:校正聊天里嵌入选择题;选项在 `.message-bubble { overflow: hidden }` 内。新题会 `focus()` 第一项,点击后按钮仍保持 `:focus-visible`
- 根因:`:focus-visible` 使用 `outline-offset: 2px`(会话页还有 `3px`),描边画在按钮外面,被气泡裁切。选中态 `border-color: var(--color-action)` 与焦点色相同,叠成双层粗边。
- 修复:焦点环改为 `outline-offset: -2px` 画在按钮内。已选中项不再叠加 outline(高对比模式除外)。嵌入卡不再自动聚焦第一项。
- 验证:`rectification-varga-style-copy` 锁 inset offset、选中无 outline、卡片不 `.focus()`
- 防复发:选择题焦点环不得使用正 `outline-offset`。选中态只保留 `data-selected` 的边框和底色。
- 相关记录:BUG-490
- 复发自:无
- 修复版本:待发布
+5 -1
View File
@@ -11,7 +11,11 @@
.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); }
.birth-time-choice-option:active:not(:disabled) { transform: scale(.96); }
.birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); }
.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.birth-time-choice-option[data-selected="true"]:focus-visible { outline: none; }
@media (forced-colors: active) {
.birth-time-choice-option[data-selected="true"]:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
}
.birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; }
.birth-time-choice-pending { margin: 0; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-unmatched-note { display: grid; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
+2
View File
@@ -930,6 +930,8 @@ button:disabled { cursor: default; opacity: .45; }
.conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; }
.conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; }
.conversational-rectification :where(button, textarea):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; }
.conversational-rectification .birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.conversational-rectification .birth-time-choice-option[data-selected="true"]:focus-visible { outline: none; }
.conversational-narrative { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-soft); }
.conversational-narrative-body { padding: var(--space-4) var(--space-5); }
.conversational-narrative .message-markdown { overflow-wrap: anywhere; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.8; }
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import type {
ChoiceKey,
RectificationChoiceCard as ChoiceCard,
@@ -20,14 +20,8 @@ type RectificationChoiceCardProps = Readonly<{
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
const selectedKey = props.selectedKey || localSelected;
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
const answered = Boolean(selectedKey);
useEffect(() => {
if (props.pending || answered) return;
firstChoiceRef.current?.focus();
}, [answered, props.card.question_id, props.pending]);
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
setLocalSelected(key);
@@ -58,10 +52,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
: <legend>{props.card.prompt}</legend>}
{!hidePrompt && props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
<div className="birth-time-primary-choices">
{props.card.options.map((option, index) => (
{props.card.options.map((option) => (
<button
key={option.key}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
@@ -353,3 +353,26 @@ test("embedded choice cards hide the duplicate legend; standalone cards keep the
assert.match(card, /className="rectification-choice-why"/);
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
});
test("choice option focus ring stays inside the button and does not stack on the selected border", () => {
const choiceCss = readFileSync(new URL("../src/app/birth-time-choice.css", import.meta.url), "utf8");
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8");
const focusRule = choiceCss.match(/\.birth-time-choice-option:focus-visible \{[^}]+\}/)?.[0] ?? "";
const selectedFocusRule = choiceCss.match(
/\.birth-time-choice-option\[data-selected="true"\]:focus-visible \{[^}]+\}/,
)?.[0] ?? "";
assert.match(focusRule, /outline-offset:\s*-2px/);
assert.doesNotMatch(focusRule, /outline-offset:\s*[1-9]/);
assert.match(selectedFocusRule, /outline:\s*none/);
assert.match(
globals,
/\.conversational-rectification \.birth-time-choice-option:focus-visible \{[^}]*outline-offset:\s*-2px/,
);
assert.match(
globals,
/\.conversational-rectification \.birth-time-choice-option\[data-selected="true"\]:focus-visible \{[^}]*outline:\s*none/,
);
assert.doesNotMatch(card, /firstChoiceRef/);
assert.doesNotMatch(card, /\.focus\(/);
});