diff --git a/packages/views/onboarding/components/icon-option-card.tsx b/packages/views/onboarding/components/icon-option-card.tsx
index 4f3c6bf54..be8c6da6a 100644
--- a/packages/views/onboarding/components/icon-option-card.tsx
+++ b/packages/views/onboarding/components/icon-option-card.tsx
@@ -74,7 +74,6 @@ export function IconOtherOptionCard({
onConfirm,
placeholder,
mode = "radio",
- allowToggleOff = false,
}: {
icon: ReactNode;
label: string;
@@ -85,34 +84,13 @@ export function IconOtherOptionCard({
onConfirm: () => void;
placeholder: string;
mode?: "radio" | "checkbox";
- /**
- * When true, clicking the card while it is already selected fires
- * `onSelect` again so the parent can toggle the pick off (multi-select
- * pattern, used by the source-backfill modal). Default `false`
- * preserves the original onboarding StepSource behaviour: a re-click
- * on the selected Other card is a no-op so users can move focus
- * into the text input without deselecting. Clicks that originate
- * inside the input never toggle — the input stops their propagation.
- */
- allowToggleOff?: boolean;
}) {
return (
{
- // In `allowToggleOff` mode, clicking the row again deselects.
- // Skip when the click originated on the inner text input so
- // typing / focusing it doesn't accidentally deselect — clicks
- // on the icon, padding, or label area still toggle.
- if (
- allowToggleOff &&
- selected &&
- e.target instanceof HTMLInputElement
- ) {
- return;
- }
- if (allowToggleOff || !selected) onSelect();
+ onClick={() => {
+ if (!selected) onSelect();
}}
className={cn(
"flex w-full items-center gap-3 rounded-xl border bg-card px-4 py-3 text-left transition-all",
diff --git a/packages/views/onboarding/source-backfill-modal.test.tsx b/packages/views/onboarding/source-backfill-modal.test.tsx
index c77e0037f..8ef72e0a2 100644
--- a/packages/views/onboarding/source-backfill-modal.test.tsx
+++ b/packages/views/onboarding/source-backfill-modal.test.tsx
@@ -216,14 +216,10 @@ describe("SourceBackfillModal", () => {
expect(await screen.findByText("GitHub")).toBeInTheDocument();
});
- it("Other toggles off on the second click instead of getting stuck", async () => {
- // Regression: an earlier version held a parallel `pendingOther`
- // flag that flipped to true on every Other click. A second click
- // then removed "other" from `source` but the row's onClick guard
- // (`if (!selected) onSelect()`) ALSO swallowed re-clicks while
- // selected, so the toggle-off never fired. Fixed by dropping
- // pendingOther AND opting the modal's Other card into the new
- // `allowToggleOff` prop on `IconOtherOptionCard`.
+ it("picking a second option replaces the first (single-select primary source)", async () => {
+ // The modal is now a single-select radio. Industry default for
+ // HDYHAU is to capture the primary acquisition source, so picking
+ // a second option must replace the first — never accumulate.
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
@@ -231,37 +227,30 @@ describe("SourceBackfillModal", () => {
});
const user = userEvent.setup();
renderModal();
- // Wait for the modal to render. Pick the Other card by its
- // checkbox role — `role="checkbox"` + matching `aria-checked`
- // stays stable whether the label is showing or the input has
- // replaced it.
await screen.findByText("Friends or colleagues");
- const checkboxes = screen.getAllByRole("checkbox");
- // 12 options: 11 normal + 1 Other. Other is the last per the
- // component's option array.
- const other = checkboxes[checkboxes.length - 1]!;
- const friends = checkboxes[0]!;
+ const radios = screen.getAllByRole("radio");
+ const friends = radios[0]!;
+ const search = radios[1]!;
- // Tick Other → Submit disabled (no free-text yet)
- await user.click(other);
- expect(other).toHaveAttribute("aria-checked", "true");
- expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
- // Add a real selection so we can test the un-tick path without
- // the validator hiding the bug behind the empty-other-text branch.
await user.click(friends);
- expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
- // Un-tick Other → still enabled because friends is still picked.
- await user.click(other);
- expect(other).toHaveAttribute("aria-checked", "false");
- expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
- // Critically: submit and assert "other" is NOT in the payload.
+ expect(friends).toHaveAttribute("aria-checked", "true");
+ expect(search).toHaveAttribute("aria-checked", "false");
+
+ // Pick a second option — the first must clear and Submit stays
+ // enabled with exactly one pick in the payload.
+ await user.click(search);
+ expect(friends).toHaveAttribute("aria-checked", "false");
+ expect(search).toHaveAttribute("aria-checked", "true");
+
await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
});
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
- expect(sent.source).toEqual(["friends_colleagues"]);
- expect(sent.source).not.toContain("other");
+ // Server schema is still `source: string[]` for back-compat with
+ // v2 rows; the client always sends a single-element array.
+ expect(sent.source).toEqual(["search"]);
+ expect(sent.source).not.toContain("friends_colleagues");
});
it("defers the entrance by ~700ms when the user has not opted into reduced motion", async () => {
diff --git a/packages/views/onboarding/source-backfill-modal.tsx b/packages/views/onboarding/source-backfill-modal.tsx
index 492dc2327..39532aecc 100644
--- a/packages/views/onboarding/source-backfill-modal.tsx
+++ b/packages/views/onboarding/source-backfill-modal.tsx
@@ -196,56 +196,45 @@ function SourceBackfillDialogBody({
[t],
);
- const selected = useMemo(
- () => [
- ...answers.source,
- ...(!answers.source.includes("other") && answers.source_other
- ? ["other"]
- : []),
- ],
- [answers.source, answers.source_other],
- );
-
+ // Single-select: at most one slug in `source` at any time. The server
+ // schema keeps the array (back-compat with the v2 multi-select shape
+ // and with existing rows), but the modal UI commits exactly one pick
+ // — primary-source attribution is the documented industry default for
+ // HDYHAU prompts (Fairing, Recast, HockeyStack) and gives the team
+ // clean channel weights without splitting users across N buckets.
+ const pickedSlug: string | null = answers.source[0] ?? null;
const otherOption = options.find((o) => o.isOther) ?? null;
- // Single source of truth for "is Other ticked": derive from `source`
- // directly, NOT a parallel useState flag. The previous version kept a
- // `pendingOther` state set to true on every Other click — which meant
- // a second click toggled "other" off in `source` but left
- // `pendingOther` stuck at true, so `otherActive` (its OR with
- // selected) never flipped back and the card visually stayed selected
- // (caught in UAT).
- const otherSelected = otherOption
- ? selected.includes(otherOption.slug)
- : false;
+ const otherSelected = pickedSlug === otherOption?.slug;
const otherFilled = (answers.source_other ?? "").trim().length > 0;
- const hasNonOtherSelection = selected.some(
- (slug) => slug !== otherOption?.slug,
- );
const canSubmit =
!busy &&
- selected.length > 0 &&
- (hasNonOtherSelection || !otherSelected || otherFilled);
+ pickedSlug !== null &&
+ // If the user picked Other, gate Submit on having typed something —
+ // an empty Other selection isn't useful attribution data.
+ (!otherSelected || otherFilled);
const handleSelect = useCallback(
(option: QuestionOption) => {
if (option.isOther) {
- setAnswers((a) => {
- const has = a.source.includes("other");
- return has
- ? { ...a, source: a.source.filter((s) => s !== "other"), source_other: null }
- : { ...a, source: [...a.source, "other"], source_skipped: false };
- });
+ const slug = option.slug as Source;
+ setAnswers((a) => ({
+ ...a,
+ source: [slug],
+ // Picking Other doesn't carry text from a prior Other pick
+ // forward: the text input auto-focuses fresh so the user can
+ // type immediately. A previous text value would be misleading.
+ source_other: a.source[0] === "other" ? a.source_other : null,
+ source_skipped: false,
+ }));
return;
}
const slug = option.slug as Source;
- setAnswers((a) => {
- const has = a.source.includes(slug);
- return {
- ...a,
- source: has ? a.source.filter((s) => s !== slug) : [...a.source, slug],
- source_skipped: false,
- };
- });
+ setAnswers((a) => ({
+ ...a,
+ source: [slug],
+ source_other: null,
+ source_skipped: false,
+ }));
},
[],
);
@@ -318,7 +307,7 @@ function SourceBackfillDialogBody({