From 69d0d7eddfb2a52ee85ee540a46e826e00fcf185 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 21:13:18 -0800 Subject: [PATCH 1/5] feat(web): ask questions one at a time, with step tabs and keys --- apps/web/src/config.ts | 21 +- apps/web/src/pages/settings.tsx | 17 +- .../workspace/composer-placeholder.test.ts | 12 + .../pages/workspace/composer-placeholder.ts | 3 + apps/web/src/pages/workspace/index.tsx | 12 +- .../src/pages/workspace/questions-card.css | 106 +++++ .../pages/workspace/questions-card.test.tsx | 98 ++++ .../src/pages/workspace/questions-card.tsx | 419 +++++++++++++++++- apps/web/src/pages/workspace/transcript.tsx | 21 + apps/web/src/questions-layout-setting.ts | 20 + 10 files changed, 704 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/pages/workspace/questions-card.test.tsx create mode 100644 apps/web/src/questions-layout-setting.ts diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 310a7180..1688c12d 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -961,9 +961,28 @@ export const wording = { }, /** The coordinator's questions as a card the person answers in the conversation. */ questions: { - title: "A few questions before going on", + title: (count: number) => + count === 1 + ? "A question for you" + : count === 2 + ? "A couple of questions for you" + : "A few questions for you", other: "Other", otherAnswer: "Your own answer", + /** The last step tab, where every answer is shown before it is sent. */ + submit: "Submit", + steps: "Questions", + /** The keyboard hint under each step, by the kind of step. */ + keys: { + single: "↑↓ move · Enter choose · ← back", + multi: "↑↓ move · Space tick · Enter next · ← back", + review: "Enter send · ← back", + }, + composerPlaceholder: "Type here to respond to the above question", + /** The Settings switch between one question at a time and all at once. */ + layoutSetting: "Ask questions one at a time", + layoutHint: + "On, each question is its own step you answer from the keyboard. Off, every question shows at once.", send: "Send answers", sending: "Sending…", notSent: "Your answers were not sent. Your choices are kept; try again.", diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings.tsx index 383373a1..e27bbf0f 100644 --- a/apps/web/src/pages/settings.tsx +++ b/apps/web/src/pages/settings.tsx @@ -28,6 +28,10 @@ import { useDocumentTips, writeDocumentTips, } from "../document-tips-setting.ts"; +import { + useQuestionsLayout, + writeQuestionsLayout, +} from "../questions-layout-setting.ts"; import { addCustomReviewerPersona, addWorkspaceReviewer, @@ -438,10 +442,11 @@ export function WorkspaceApprovals() { ); } -/** The theme, and the document tips choice kept in this browser. */ +/** The theme, and the document tips and questions layout choices kept in this browser. */ function Appearance() { const { mode, setMode } = useTheme(); const tips = useDocumentTips(); + const questionsLayout = useQuestionsLayout(); const copy = wording.settings.appearance; return (
@@ -466,6 +471,16 @@ function Appearance() { onCheckedChange={(on) => writeDocumentTips(on ? "on" : "off")} /> + + writeQuestionsLayout(on ? "steps" : "all")} + /> +
); diff --git a/apps/web/src/pages/workspace/composer-placeholder.test.ts b/apps/web/src/pages/workspace/composer-placeholder.test.ts index 8282e778..383587c0 100644 --- a/apps/web/src/pages/workspace/composer-placeholder.test.ts +++ b/apps/web/src/pages/workspace/composer-placeholder.test.ts @@ -21,11 +21,13 @@ function standing(input: { function placeholder(input: { approvalOpen?: boolean; + questionsOpen?: boolean; chatFirst?: boolean; status?: ProjectStatus; }): string { return composerPlaceholder({ approvalOpen: input.approvalOpen ?? false, + questionsOpen: input.questionsOpen ?? false, chatFirst: input.chatFirst ?? false, status: input.status, }); @@ -154,4 +156,14 @@ describe("composer placeholder", () => { }), ).toBe(wording.approval.composerPlaceholder); }); + + test("open questions point at the card, after an open approval", () => { + const status = standing({ working: [{ role: "coder", state: "working" }] }); + expect(placeholder({ questionsOpen: true, status })).toBe( + wording.questions.composerPlaceholder, + ); + expect( + placeholder({ approvalOpen: true, questionsOpen: true, status }), + ).toBe(wording.approval.composerPlaceholder); + }); }); diff --git a/apps/web/src/pages/workspace/composer-placeholder.ts b/apps/web/src/pages/workspace/composer-placeholder.ts index a2b3ad02..9ccf07f2 100644 --- a/apps/web/src/pages/workspace/composer-placeholder.ts +++ b/apps/web/src/pages/workspace/composer-placeholder.ts @@ -23,12 +23,15 @@ function passAlongRole( export function composerPlaceholder(input: { approvalOpen: boolean; + /** The coordinator's questions wait on the person; a message sent instead answers them in its own words. */ + questionsOpen: boolean; /** Absent until project status has loaded. */ status: ProjectStatus | undefined; /** No pane deliverables, design draft, or concepts yet. */ chatFirst: boolean; }): string { if (input.approvalOpen) return wording.approval.composerPlaceholder; + if (input.questionsOpen) return wording.questions.composerPlaceholder; const status = input.status; if (status === undefined) return copy.reply; const role = passAlongRole(status.working); diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 25169095..24592fca 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -71,7 +71,12 @@ import { import type { ChatMessage, Wait } from "../../api/types.ts"; import { keys } from "../../queries/keys.ts"; import { COMPOSER_BOX_CLASS, CONV_CLASS, PANES_CLASS } from "./pane-classes.ts"; -import { RoomThread, turnItemId, type TranscriptTurn } from "./transcript.tsx"; +import { + questionsOpen, + RoomThread, + turnItemId, + type TranscriptTurn, +} from "./transcript.tsx"; import { cardDocuments } from "./collaboration.ts"; import { useFiledVersions } from "./filed-versions.tsx"; import { threadUpdates } from "./specialist-updates.tsx"; @@ -124,6 +129,7 @@ import { DESIGN_OPTION, } from "./design-concepts.tsx"; import { useLiveAppPresence } from "./live-app.tsx"; +import { useQuestionsLayout } from "../../questions-layout-setting.ts"; import { NextStepLine, useProjectNextStep } from "./next-step.tsx"; import { composerPlaceholder } from "./composer-placeholder.ts"; import { Dictated } from "../../dictation.jsx"; @@ -348,6 +354,7 @@ export function StageWorkspace({ .map(paneArtifact); const documents = snapshot?.documents ?? {}; const [liveRun, setLiveRun] = useState(emptyLiveRun); + const questionsLayout = useQuestionsLayout(); const [approvalBusy, setApprovalBusy] = useState(false); const [approvalError, setApprovalError] = useState(null); const [changesFor, setChangesFor] = useState(null); @@ -1422,6 +1429,8 @@ export function StageWorkspace({ ref={composerRef} placeholder={composerPlaceholder({ approvalOpen: openApproval !== undefined, + questionsOpen: + !archived && questionsOpen(shownTurns, liveRun.results), status: projectStatus.data, chatFirst, })} @@ -1541,6 +1550,7 @@ export function StageWorkspace({ {...(sentId === undefined ? {} : { sentId })} onAnswerQuestions={(reply) => deliver(reply)} questionsDisabled={sending || archived} + questionsLayout={questionsLayout} > {archived ? ( p:first-child { + display: none; +} + +.questions-card > h3 { + margin-top: 0; +} + +.questions-card-steps { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-3); +} + +.questions-card-step { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.questions-card-step[aria-selected="true"] { + border-color: var(--primary-emphasis); + color: var(--foreground); +} + +.questions-card-step:disabled { + opacity: 0.5; +} + +.questions-card-step-mark { + width: 0.75rem; + height: 0.75rem; + color: var(--ok); +} + +.questions-card-prompt { + margin: 0; + font-size: 0.875rem; + font-weight: 500; +} + +.questions-card-options { + display: flex; + flex-direction: column; + gap: 2px; + border-radius: var(--radius-sm); + outline: none; +} + +.questions-card-choice { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 2rem; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: 0.875rem; + cursor: pointer; +} + +.questions-card-options:focus-visible .questions-card-choice[data-active], +.questions-card-choice:hover { + background: color-mix(in oklab, var(--primary-emphasis) 12%, transparent); +} + +.questions-card-choice-mark { + flex: none; + width: 1rem; + height: 1rem; + color: var(--primary-emphasis); +} + +.questions-card-choice .questions-card-other-input { + flex: 1 1 auto; + min-width: 0; +} + +.questions-card-review dl { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0 0 var(--space-3); + font-size: 0.875rem; +} + +.questions-card-review dt { + color: var(--muted-foreground); +} + +.questions-card-review dd { + margin: 0; +} + +.questions-card-keys { + margin: var(--space-3) 0 0; + font-size: 0.6875rem; + color: var(--muted-foreground); +} + .questions-card-fields { display: flex; flex-direction: column; diff --git a/apps/web/src/pages/workspace/questions-card.test.tsx b/apps/web/src/pages/workspace/questions-card.test.tsx new file mode 100644 index 00000000..64285f85 --- /dev/null +++ b/apps/web/src/pages/workspace/questions-card.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AskQuestions } from "@corbits-builder/schemas/questions"; +import { wording } from "../../config.ts"; +import { + QuestionsCard, + stepName, + type QuestionsState, +} from "./questions-card.tsx"; + +const text = wording.questions; + +const ask: AskQuestions = { + questions: [ + { + id: "audience", + prompt: "Who is it for?", + kind: "single", + options: [ + { value: "teams", label: "Small teams" }, + { value: "solo", label: "Just me" }, + ], + preselected: ["teams"], + }, + { + id: "first_version", + prompt: "What must the first version do?", + kind: "multi", + options: [{ value: "track", label: "Track tasks" }], + allowOther: true, + }, + ], +}; + +const open: QuestionsState = { + kind: "open", + disabled: false, + onSend: () => Promise.resolve(true), +}; + +describe("questions card", () => { + test("a step is named from its question id", () => { + expect(stepName("audience")).toBe("Audience"); + expect(stepName("first_version")).toBe("First version"); + expect(stepName("who-signs in")).toBe("Who signs in"); + }); + + test("the heading counts the questions", () => { + expect(text.title(1)).toBe("A question for you"); + expect(text.title(2)).toBe("A couple of questions for you"); + expect(text.title(5)).toBe("A few questions for you"); + }); + + test("one at a time, the first question shows under step tabs ending in Submit", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(text.title(2)); + expect(html).toContain('role="tablist"'); + expect(html).toContain(">Audience<"); + expect(html).toContain(">First version<"); + expect(html).toContain(`>${text.submit}<`); + expect(html).toContain('role="listbox"'); + expect(html).toContain("Small teams"); + expect(html).not.toContain("What must the first version do?"); + expect(html).toContain(text.keys.single); + expect(html).not.toContain("questions-card-step-mark"); + }); + + test("all at once, every question shows and Other is an option until chosen", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Who is it for?"); + expect(html).toContain("What must the first version do?"); + expect(html).toContain(`>${text.other}<`); + expect(html).not.toContain(`placeholder="${text.other}"`); + expect(html).not.toContain('role="tablist"'); + }); + + test("an answered card settles whichever layout asked it", () => { + const reply = [ + text.answerLine("Who is it for?", ["Small teams"]), + text.answerLine("What must the first version do?", ["Track tasks"]), + ].join("\n"); + for (const layout of ["steps", "all"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(text.answered(["Small teams", "Track tasks"])); + } + }); +}); diff --git a/apps/web/src/pages/workspace/questions-card.tsx b/apps/web/src/pages/workspace/questions-card.tsx index 467fb598..10bbe7d1 100644 --- a/apps/web/src/pages/workspace/questions-card.tsx +++ b/apps/web/src/pages/workspace/questions-card.tsx @@ -3,7 +3,7 @@ * person's next message, the way the composer sends one; the card holds no state of its own past * that message, so a later person turn is what marks it answered. */ -import { useState } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { Check, CornerDownRight } from "lucide-react"; import { Button, @@ -20,6 +20,7 @@ import { type Question, } from "@corbits-builder/schemas/questions"; import { wording } from "../../config.ts"; +import type { QuestionsLayout } from "../../questions-layout-setting.ts"; import type { CallResult } from "./live-run.ts"; import "./questions-card.css"; @@ -67,6 +68,15 @@ function complete(answer: Answer): boolean { return answer.chosen.length > 0; } +/** A step tab's name: the question id with its separators as spaces, "first_version" as "First version". */ +export function stepName(id: string): string { + const words = id + .split(/[-_\s]+/) + .filter(Boolean) + .join(" "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + /** Each question's answer as the person's reply carries it, by the line the card composed. */ function repliedAnswers( questions: readonly Question[], @@ -108,20 +118,27 @@ function OtherInput({ value, disabled, onChange, + onKeyDown, + onBlur, }: { question: Question; value: string; disabled: boolean; onChange: (value: string) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onBlur?: () => void; }) { return ( onChange(event.target.value)} + {...(onKeyDown === undefined ? {} : { onKeyDown })} + {...(onBlur === undefined ? {} : { onBlur })} /> ); } @@ -197,15 +214,18 @@ function SingleField({ function MultiField({ question, + idPrefix, answer, disabled, onChange, }: { question: Question; + idPrefix: string; answer: Answer; disabled: boolean; onChange: (answer: Answer) => void; }) { + const otherId = `${idPrefix}-${question.id}-other`; return (
{question.prompt} @@ -226,42 +246,372 @@ function MultiField({ } /> ))} - {question.allowOther === true ? ( + {question.allowOther !== true ? null : answer.other ? ( + // Ticked, Other becomes the field it names; left empty, it unticks.
onChange({ ...answer, other })} + onCheckedChange={() => + onChange({ ...answer, other: false, otherText: "" }) + } /> - onChange({ - ...answer, - other: otherText !== "" || answer.other, - otherText, - }) - } + onChange={(otherText) => onChange({ ...answer, otherText })} + onBlur={() => { + if (answer.otherText.trim() === "") + onChange({ ...answer, other: false, otherText: "" }); + }} />
- ) : null} + ) : ( + onChange({ ...answer, other: true })} + /> + )}
); } +/** One question as a list answered from the keyboard: arrows move, Enter chooses, Space ticks. */ +function StepQuestion({ + question, + idPrefix, + answer, + disabled, + focus, + onChange, + onDone, + onBack, +}: { + question: Question; + idPrefix: string; + answer: Answer; + disabled: boolean; + /** Whether the list takes focus as it opens: only once the person is moving through the steps. */ + focus: boolean; + onChange: (answer: Answer) => void; + /** Moves on, given the answer this step settled on. */ + onDone: (answer: Answer) => void; + onBack: () => void; +}) { + const multi = question.kind === "multi"; + const allowOther = question.allowOther === true; + const count = question.options.length + (allowOther ? 1 : 0); + const otherIndex = allowOther ? question.options.length : -1; + const [active, setActive] = useState(() => { + if (answer.other) return otherIndex; + const chosen = question.options.findIndex((option) => + answer.chosen.includes(option.value), + ); + return Math.max(chosen, 0); + }); + const list = useRef(null); + // Focus follows the step as it opens, not each answer within it. + const opening = useRef(focus && !answer.other); + useEffect(() => { + if (opening.current) list.current?.focus(); + }, []); + + const pick = (index: number): Answer => { + if (index === otherIndex) + return multi + ? { ...answer, other: true } + : { chosen: [], other: true, otherText: answer.otherText }; + const value = question.options[index]?.value; + if (value === undefined) return answer; + if (!multi) return { chosen: [value], other: false, otherText: "" }; + return { + ...answer, + chosen: answer.chosen.includes(value) + ? answer.chosen.filter((item) => item !== value) + : [...answer.chosen, value], + }; + }; + const choose = (index: number) => { + const next = pick(index); + onChange(next); + if (!multi && index !== otherIndex) onDone(next); + }; + const leaveOther = () => { + if (answer.otherText.trim() === "") + onChange({ ...answer, other: false, otherText: "" }); + list.current?.focus(); + }; + + const onListKey = (event: KeyboardEvent) => { + if (disabled || event.target !== event.currentTarget) return; + const keys: Record void> = { + ArrowDown: () => setActive((index) => Math.min(index + 1, count - 1)), + ArrowUp: () => setActive((index) => Math.max(index - 1, 0)), + ArrowLeft: onBack, + ArrowRight: () => { + if (complete(answer)) onDone(answer); + }, + " ": () => onChange(pick(active)), + Enter: () => { + if (!multi || active === otherIndex) return choose(active); + if (complete(answer)) return onDone(answer); + const next = pick(active); + onChange(next); + onDone(next); + }, + }; + const run = keys[event.key]; + if (run === undefined) return; + event.preventDefault(); + run(); + }; + const onOtherKey = (event: KeyboardEvent) => { + if (event.key === "Enter" && complete(answer)) { + event.preventDefault(); + onDone(answer); + } + if (event.key === "Escape" || event.key === "ArrowUp") { + event.preventDefault(); + if (event.key === "ArrowUp") setActive(Math.max(otherIndex - 1, 0)); + leaveOther(); + } + }; + + const optionId = (index: number) => `${idPrefix}-${question.id}-${index}`; + const chosen = (index: number) => + index === otherIndex + ? answer.other + : answer.chosen.includes(question.options[index]?.value ?? ""); + const labels = [ + ...question.options.map((option) => option.label), + ...(allowOther ? [text.other] : []), + ]; + return ( +
+

+ {question.prompt} +

+
+ {labels.map((label, index) => + index === otherIndex && answer.other ? ( +
+ + onChange({ ...answer, otherText })} + onKeyDown={onOtherKey} + onBlur={() => { + if (answer.otherText.trim() === "") + onChange({ ...answer, other: false, otherText: "" }); + }} + /> +
+ ) : ( +
{ + if (disabled) return; + setActive(index); + if (multi && index !== otherIndex) list.current?.focus(); + choose(index); + }} + > + {chosen(index) ? ( + + ) : ( + + )} + {label} +
+ ), + )} +
+
+ ); +} + +/** Every answer before it is sent. Enter sends; ← goes back to the last question. */ +function StepReview({ + questions, + answerOf, + focus, + onBack, + onSend, + canSend, + sending, +}: { + questions: readonly Question[]; + answerOf: (question: Question) => Answer; + focus: boolean; + onBack: () => void; + onSend: () => void; + canSend: boolean; + sending: boolean; +}) { + return ( +
{ + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + onBack(); + }} + > +
+ {questions.map((question) => ( +
+
{question.prompt}
+
{answerLabels(question, answerOf(question)).join(", ")}
+
+ ))} +
+ +
+ ); +} + +function StepsBody({ + idPrefix, + questions, + answerOf, + setAnswer, + disabled, + sending, + onSend, +}: { + idPrefix: string; + questions: readonly Question[]; + answerOf: (question: Question) => Answer; + setAnswer: (question: Question, answer: Answer) => void; + disabled: boolean; + sending: boolean; + onSend: () => void; +}) { + const [step, setStep] = useState(0); + const [moved, setMoved] = useState(false); + // The furthest step reached: every question is seen in turn, even one with a preselected answer. + const [furthest, setFurthest] = useState(0); + const go = (next: number) => { + const to = Math.max(0, Math.min(next, questions.length)); + setMoved(true); + setStep(to); + setFurthest((current) => Math.max(current, to)); + }; + const answeredAll = questions.every((item) => complete(answerOf(item))); + const question = questions[step]; + return ( + <> +
+ {[...questions.map((item) => stepName(item.id)), text.submit].map( + (name, index) => { + const item = questions[index]; + const done = + item !== undefined && + index < furthest && + complete(answerOf(item)); + return ( + + ); + }, + )} +
+ {question === undefined ? ( + go(step - 1)} + onSend={onSend} + canSend={!disabled && !sending && answeredAll} + sending={sending} + /> + ) : ( + setAnswer(question, answer)} + onDone={(answer) => { + if (complete(answer)) go(step + 1); + }} + onBack={() => go(step - 1)} + /> + )} +

+ {question === undefined ? text.keys.review : text.keys[question.kind]} +

+ + ); +} + export function QuestionsCard({ id, ask, state, + layout, }: { /** The call's id, so each card's field ids are its own. */ id: string; ask: AskQuestions; state: QuestionsState; + layout: QuestionsLayout; }) { const [answers, setAnswers] = useState>(() => Object.fromEntries( @@ -278,6 +628,8 @@ export function QuestionsCard({ if (state.kind === "replaced") return ; const answerOf = (question: Question): Answer => answers[question.id] ?? { chosen: [], other: false, otherText: "" }; + const setAnswer = (question: Question, next: Answer) => + setAnswers((current) => ({ ...current, [question.id]: next })); const ready = ask.questions.every((question) => complete(answerOf(question))); const disabled = state.disabled || sending; const send = async () => { @@ -295,10 +647,37 @@ export function QuestionsCard({ setSending(false); setFailed(!sent); }; + const notSent = failed ? ( +

+ {text.notSent} +

+ ) : undefined; + const gate = { + kind: "form" as const, + title: text.title(ask.questions.length), + }; + if (layout === "steps") + return ( + + void send()} + /> + + ); return ( - {failed ? ( -

- {text.notSent} -

- ) : null} + {notSent} } >
{ask.questions.map((question) => { const answer = answerOf(question); - const onChange = (next: Answer) => - setAnswers((current) => ({ ...current, [question.id]: next })); + const onChange = (next: Answer) => setAnswer(question, next); return question.kind === "single" ? ( , +): boolean { + const since = turns.findLastIndex((turn) => turn.role === "person") + 1; + return turns + .slice(since) + .some((turn) => + toolsOf(visibleTurnParts(turn)).some( + (tool) => + askedQuestions(tool, results.get(tool.toolCallId)) !== undefined, + ), + ); +} + /** * The thread cut at the person's turns. Between two of them the coordinator * speaks once: its turns' words under one header, then every card from that @@ -591,6 +608,7 @@ export function RoomThread({ typedYes, onAnswerQuestions, questionsDisabled = false, + questionsLayout = "steps", children, }: { turns: readonly TranscriptTurn[]; @@ -628,6 +646,8 @@ export function RoomThread({ /** Sends the person's answers to the coordinator's questions as their next message. */ onAnswerQuestions?: (reply: string) => Promise; questionsDisabled?: boolean; + /** Whether question cards ask one question at a time or all at once. */ + questionsLayout?: QuestionsLayout; /** Agent output rendered in the same log, after the turns. */ children?: ReactNode; }) { @@ -689,6 +709,7 @@ export function RoomThread({ id={card.callId} ask={card.ask} state={questions(card.callId)} + layout={questionsLayout} /> diff --git a/apps/web/src/questions-layout-setting.ts b/apps/web/src/questions-layout-setting.ts new file mode 100644 index 00000000..77d6f7ce --- /dev/null +++ b/apps/web/src/questions-layout-setting.ts @@ -0,0 +1,20 @@ +/** Whether the coordinator's questions come one at a time or all at once, chosen in Settings and kept in this browser. */ +import { + useStoredPreference, + writeStoredPreference, +} from "./stored-preference.ts"; + +const LAYOUT_KEY = "builder-questions-layout"; + +export type QuestionsLayout = "steps" | "all"; + +const parseLayout = (raw: string): QuestionsLayout | null => + raw === "steps" || raw === "all" ? raw : null; + +export function useQuestionsLayout(): QuestionsLayout { + return useStoredPreference(LAYOUT_KEY, parseLayout, "steps"); +} + +export function writeQuestionsLayout(layout: QuestionsLayout): void { + writeStoredPreference(LAYOUT_KEY, layout); +} From 228fc91fe83e0af398f9b05cb3840267e5daef08 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 21:31:54 -0800 Subject: [PATCH 2/5] feat(web): settle a questions card into a quiet box with its answers --- apps/web/src/config.ts | 9 +-- .../src/pages/workspace/questions-card.css | 36 +++++++-- .../pages/workspace/questions-card.test.tsx | 30 +++++-- .../src/pages/workspace/questions-card.tsx | 78 ++++++++++++------- 4 files changed, 104 insertions(+), 49 deletions(-) diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 1688c12d..2971f896 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -986,11 +986,10 @@ export const wording = { send: "Send answers", sending: "Sending…", notSent: "Your answers were not sent. Your choices are kept; try again.", - /** The settled card's one line: each question's answer, in order. */ - answered: (answers: readonly string[]) => - `Answered: ${answers.join(" · ")}`, - answeredInChat: "Answered in the conversation.", - replaced: "Replaced by the questions that followed.", + /** The settled card's heading, over each question and its answer. */ + answered: "Answered", + answeredInChat: "Answered in the conversation", + replaced: "Replaced by the questions that followed", /** One list item of the person's reply per question: "- Who signs in? — Staff, Guests". */ answerLine: (prompt: string, answers: readonly string[]) => `- ${prompt} — ${answers.join(", ")}`, diff --git a/apps/web/src/pages/workspace/questions-card.css b/apps/web/src/pages/workspace/questions-card.css index 9e8f3fde..1ac1cdfc 100644 --- a/apps/web/src/pages/workspace/questions-card.css +++ b/apps/web/src/pages/workspace/questions-card.css @@ -87,7 +87,7 @@ min-width: 0; } -.questions-card-review dl { +.questions-card-answers { display: flex; flex-direction: column; gap: var(--space-2); @@ -95,12 +95,13 @@ font-size: 0.875rem; } -.questions-card-review dt { +.questions-card-answers dt { color: var(--muted-foreground); } -.questions-card-review dd { +.questions-card-answers dd { margin: 0; + overflow-wrap: anywhere; } .questions-card-keys { @@ -149,16 +150,35 @@ min-width: 0; } +/* The open card's frame, quiet: done, not waiting on anyone. */ .questions-card-settled { - justify-content: flex-start; - text-align: start; + max-width: 100%; + min-width: 0; + padding: 0.75rem 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg, 0.5rem); } -.questions-card-settled > span:last-child { - min-width: 0; - overflow-wrap: anywhere; +.questions-card-settled-title { + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0; + font-size: 0.875rem; + font-weight: 500; + color: var(--muted-foreground); +} + +.questions-card-settled-mark { + flex: none; + width: 1rem; + height: 1rem; } .questions-card-settled-ok { color: var(--ok); } + +.questions-card-settled .questions-card-answers { + margin: var(--space-3) 0 0; +} diff --git a/apps/web/src/pages/workspace/questions-card.test.tsx b/apps/web/src/pages/workspace/questions-card.test.tsx index 64285f85..0652155c 100644 --- a/apps/web/src/pages/workspace/questions-card.test.tsx +++ b/apps/web/src/pages/workspace/questions-card.test.tsx @@ -78,21 +78,39 @@ describe("questions card", () => { expect(html).not.toContain('role="tablist"'); }); - test("an answered card settles whichever layout asked it", () => { + test("an answered card settles the same whichever layout asked it", () => { const reply = [ text.answerLine("Who is it for?", ["Small teams"]), text.answerLine("What must the first version do?", ["Track tasks"]), ].join("\n"); - for (const layout of ["steps", "all"] as const) { - const html = renderToStaticMarkup( + const settled = (["steps", "all"] as const).map((layout) => + renderToStaticMarkup( , - ); - expect(html).toContain(text.answered(["Small teams", "Track tasks"])); - } + ), + ); + expect(settled[0]).toBe(settled[1]!); + expect(settled[0]).toContain(`aria-label="${text.answered}"`); + expect(settled[0]).toContain("
Who is it for?
Small teams
"); + expect(settled[0]).toContain( + "
What must the first version do?
Track tasks
", + ); + }); + + test("a card answered in the person's own words shows no answers", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(text.answeredInChat); + expect(html).not.toContain(" { const lead = text.answerLine(question.prompt, []); const line = lines.find((candidate) => candidate.startsWith(lead)); - return line === undefined ? [] : [line.slice(lead.length)]; + return line === undefined + ? [] + : [{ prompt: question.prompt, answer: line.slice(lead.length) }]; }); } +function AnswerList({ + answers, +}: { + answers: readonly { prompt: string; answer: string }[]; +}) { + return ( +
+ {answers.map(({ prompt, answer }) => ( +
+
{prompt}
+
{answer}
+
+ ))} +
+ ); +} + +/** The card once done, the same in either layout: its outcome, and the answers the reply carried. */ function Settled({ ask, reply }: { ask: AskQuestions; reply?: string }) { - if (reply === undefined) - return ( - } - > - {text.replaced} - - ); - const answers = repliedAnswers(ask.questions, reply); + const answers = + reply === undefined ? [] : repliedAnswers(ask.questions, reply); + const title = + reply === undefined + ? text.replaced + : answers.length === 0 + ? text.answeredInChat + : text.answered; return ( - } - > - - {answers.length === 0 ? text.answeredInChat : text.answered(answers)} - - +
+

+ {reply === undefined ? ( + + ) : ( + + )} + {title} +

+ {answers.length === 0 ? null : } +
); } @@ -487,14 +507,12 @@ function StepReview({ onBack(); }} > -
- {questions.map((question) => ( -
-
{question.prompt}
-
{answerLabels(question, answerOf(question)).join(", ")}
-
- ))} -
+ ({ + prompt: question.prompt, + answer: answerLabels(question, answerOf(question)).join(", "), + }))} + />