From bc22ea45cfbad66d183a79da3371be28c172dccf Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Sun, 21 Jun 2026 10:19:22 +0200 Subject: [PATCH 1/2] feat(app): clearer interactive question card Bring the AskUserQuestion card closer to the Claude Code CLI's clarity: - Legible typography: option labels 16px semibold, descriptions 14px, matching the surrounding conversation instead of receding below it. - Discoverable multi-select: a static left-side control (checkbox for multi-select, radio for single-select) replaces a trailing check that only appeared on selection and reflowed the row. - Multi-question nav shows each question's title, with a check on answered ones, instead of bare numbered circles. - a11y: single-select options grouped in a radiogroup; per-row checkbox/radio roles; aria-selected -> aria-checked. Updates the e2e question helper for the new option roles. No protocol change; multiSelect was already parsed on the client. Co-Authored-By: Claude Opus 4.8 --- packages/app/e2e/helpers/questions.ts | 17 +- .../app/src/components/question-form-card.tsx | 197 ++++++++++++------ 2 files changed, 148 insertions(+), 66 deletions(-) diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/helpers/questions.ts index 92c64e94a..3fc1db264 100644 --- a/packages/app/e2e/helpers/questions.ts +++ b/packages/app/e2e/helpers/questions.ts @@ -19,18 +19,19 @@ export async function expectQuestionHidden(page: Page, question: string): Promis await expect(page.getByText(question, { exact: true })).toHaveCount(0); } +// Options render as radios (single-select) or checkboxes (multi-select), so match +// either role by accessible name. +function questionOption(page: Page, option: string) { + const card = page.getByTestId("question-form-card").first(); + return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option })); +} + export async function chooseQuestionOption(page: Page, option: string): Promise { - await page - .getByTestId("question-form-card") - .first() - .getByRole("button", { name: option }) - .click(); + await questionOption(page, option).click(); } export async function expectQuestionOptionSelected(page: Page, option: string): Promise { - await expect( - page.getByTestId("question-form-card").first().getByRole("button", { name: option }), - ).toHaveAttribute("aria-selected", "true"); + await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true"); } export async function openQuestion( diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 4d2f3c5e3..28522bb79 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -92,30 +92,49 @@ function QuestionOptionRow({ () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); - const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); + + // Static left-side control: square for multi-select, circle for single-select. + // Always rendered so toggling only swaps fill/border — the row never reflows. + const controlStyle = useMemo( + () => [ + styles.selectionControl, + multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio, + { + borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted, + backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent", + }, + ], + [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted], + ); + const radioDotStyle = useMemo( + () => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }], + [theme.colors.accent], + ); return ( + + {isSelected && multiSelect ? ( + + ) : null} + {isSelected && !multiSelect ? : null} + {option.label} {option.description ? ( {option.description} ) : null} - {isSelected ? ( - - - - ) : null} ); @@ -124,7 +143,9 @@ function QuestionOptionRow({ interface QuestionNavButtonProps { index: number; total: number; + header: string; isActive: boolean; + isAnswered: boolean; isResponding: boolean; onSelect: (index: number) => void; } @@ -132,7 +153,9 @@ interface QuestionNavButtonProps { function QuestionNavButton({ index, total, + header, isActive, + isAnswered, isResponding, onSelect, }: QuestionNavButtonProps) { @@ -180,11 +203,57 @@ function QuestionNavButton({ onPress={handlePress} disabled={isResponding} > - {index + 1} + {isAnswered ? ( + + ) : null} + + {header} + ); } +interface QuestionNavProps { + questions: QuestionFormQuestion[]; + activeIndex: number; + isAnswered: (qIndex: number) => boolean; + isResponding: boolean; + onSelect: (index: number) => void; +} + +// Titled tabs (one per question header) with a check on answered ones. Hidden for +// a lone question — a single "1 of 1" tab carries no information. +function QuestionNav({ + questions, + activeIndex, + isAnswered, + isResponding, + onSelect, +}: QuestionNavProps) { + if (questions.length <= 1) { + return null; + } + return ( + + {questions.map((question, qIndex) => ( + + ))} + + ); +} + interface QuestionOtherInputProps { qIndex: number; accessibilityLabel: string; @@ -355,6 +424,12 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi setActiveQuestionIndex(index); }, []); + const navIsAnswered = useCallback( + (qIndex: number) => + questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false, + [questions, selections, otherTexts], + ); + const handlePrimaryAction = useCallback(() => { if (!isLastQuestion) { if (!activeQuestionAnswered || isResponding) return; @@ -407,9 +482,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi () => [styles.questionText, { color: theme.colors.foreground }], [theme.colors.foreground], ); - const questionNavStyle = useMemo( - () => [styles.questionNav, isMobile && styles.questionNavMobile], - [isMobile], + // Single-select radios need a group; checkboxes are valid standalone. + const optionsGroupAccessibility = useMemo( + () => + activeQuestion && !activeQuestion.multiSelect + ? ({ + accessibilityRole: "radiogroup", + accessibilityLabel: activeQuestion.question, + } as const) + : {}, + [activeQuestion], ); const actionsContainerStyle = useMemo( () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], @@ -436,33 +518,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi return ( - - - - {activeQuestion?.question} - - - - {questions.map((question, qIndex) => { - const isActive = qIndex === resolvedActiveQuestionIndex; - return ( - - ); - })} - + + + + {activeQuestion?.question} + {activeQuestion ? ( {activeQuestion.options.length > 0 ? ( - + {activeQuestion.options.map((opt, optIndex) => ( ({ questionBlock: { gap: theme.spacing[2], }, - questionTopRow: { - flexDirection: "row", - alignItems: "flex-start", - justifyContent: "space-between", - gap: theme.spacing[3], - }, questionHeader: { flexDirection: "row", alignItems: "center", @@ -571,24 +637,24 @@ const styles = StyleSheet.create((theme) => ({ }, questionNav: { flexDirection: "row", + flexWrap: "wrap", alignItems: "center", - justifyContent: "flex-end", gap: theme.spacing[1], - }, - questionNavMobile: { - paddingRight: theme.spacing[1], + paddingHorizontal: theme.spacing[3], }, questionNavButton: { - minWidth: 28, - height: 28, + flexDirection: "row", alignItems: "center", - justifyContent: "center", - borderRadius: 999, + gap: theme.spacing[1], + minHeight: 28, + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], }, questionNavText: { - fontSize: theme.fontSize.xs, - fontWeight: "700", + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, }, optionItem: { flexDirection: "row", @@ -603,25 +669,40 @@ const styles = StyleSheet.create((theme) => ({ optionItemContent: { flex: 1, flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", gap: theme.spacing[2], }, optionTextBlock: { flex: 1, - gap: 2, + gap: theme.spacing[1], }, optionLabel: { - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + lineHeight: 22, }, optionDescription: { - fontSize: theme.fontSize.xs, - lineHeight: 16, + fontSize: theme.fontSize.sm, + lineHeight: 20, }, - optionCheckSlot: { - width: 16, + selectionControl: { + width: 18, + height: 18, alignItems: "center", justifyContent: "center", - marginLeft: "auto", + borderWidth: theme.borderWidth[1], + marginTop: 2, // optical-align 18px control to the 22px label first line + }, + selectionControlCheckbox: { + borderRadius: theme.borderRadius.base, + }, + selectionControlRadio: { + borderRadius: 999, + }, + selectionRadioDot: { + width: 8, + height: 8, + borderRadius: 999, }, otherInput: { borderWidth: 1, From 34f102577cb3ec5ff83ca7fd288688d15585bf93 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Sun, 21 Jun 2026 10:51:03 +0200 Subject: [PATCH 2/2] fix(app): model question nav as a tablist for valid ARIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #1643: - The nav tabs were role="button" + aria-selected, which is invalid ARIA (aria-selected isn't defined for the button role). Model the nav as role="tablist" containing role="tab" items, which makes aria-selected valid and is semantically accurate for a question switcher. - Guard expectCurrentQuestion's nav assertion so it only runs for multi-question cards — the nav is hidden for a lone question, so the helper would otherwise time out if used on a single-question flow. Co-Authored-By: Claude Opus 4.8 --- packages/app/e2e/helpers/questions.ts | 28 +++++++++---------- .../app/src/components/question-form-card.tsx | 8 ++++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/helpers/questions.ts index 3fc1db264..ebe13aead 100644 --- a/packages/app/e2e/helpers/questions.ts +++ b/packages/app/e2e/helpers/questions.ts @@ -10,9 +10,10 @@ export async function expectCurrentQuestion( ): Promise { const card = page.getByTestId("question-form-card").first(); await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question); - await expect( - card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }), - ).toHaveAttribute("aria-selected", "true"); + // Nav tabs only render for multi-question cards (hidden for a lone question). + if (input.total > 1) { + await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true"); + } } export async function expectQuestionHidden(page: Page, question: string): Promise { @@ -26,6 +27,14 @@ function questionOption(page: Page, option: string) { return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option })); } +// The multi-question nav renders as a tablist; each question is a tab. +function questionNavTab(page: Page, input: { index: number; total: number }) { + return page + .getByTestId("question-form-card") + .first() + .getByRole("tab", { name: `Question ${input.index} of ${input.total}` }); +} + export async function chooseQuestionOption(page: Page, option: string): Promise { await questionOption(page, option).click(); } @@ -38,23 +47,14 @@ export async function openQuestion( page: Page, input: { index: number; total: number }, ): Promise { - await page - .getByTestId("question-form-card") - .first() - .getByRole("button", { name: `Question ${input.index} of ${input.total}` }) - .click(); + await questionNavTab(page, input).click(); } export async function expectQuestionNavigationEnabled( page: Page, input: { index: number; total: number }, ): Promise { - await expect( - page - .getByTestId("question-form-card") - .first() - .getByRole("button", { name: `Question ${input.index} of ${input.total}` }), - ).toBeEnabled(); + await expect(questionNavTab(page, input)).toBeEnabled(); } export async function fillQuestionAnswer( diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 28522bb79..22576a7e0 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -194,7 +194,7 @@ function QuestionNavButton({ return ( + {questions.map((question, qIndex) => (