From fffcdf6325fe0089b7344cdc380afb0d3932bb06 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:15:38 -0400 Subject: [PATCH 1/2] fix(focus): clarify approve consequences and action verbs Combine mobile trust microcopy with canonical Approve / Not now / Hide verbs, including first-run guidance and keyboard/screen-reader copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/single-action-queue.test.tsx | 14 ++++---- app/(shell)/focus/FocusModeClient.tsx | 15 ++++---- app/(shell)/focus/WalkthroughBanner.tsx | 5 +-- components/focus/SingleActionQueue.tsx | 43 +++++++++++++++-------- lib/focus/action-verbs.ts | 46 +++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 lib/focus/action-verbs.ts diff --git a/__tests__/single-action-queue.test.tsx b/__tests__/single-action-queue.test.tsx index 3f2e78d4d..95b46b0ba 100644 --- a/__tests__/single-action-queue.test.tsx +++ b/__tests__/single-action-queue.test.tsx @@ -2,7 +2,7 @@ /** * Reframe V2, piece 1 - the Single-Action Queue on /focus. * Locks the one-action-at-a-time contract: the current (first pending) action is - * shown with Approve / Snooze / Skip mapped to the 3-decision backend, a visible + * shown with Approve / Not now / Hide mapped to the 3-decision backend, a visible * "Sample" badge while the brief is sample-gated, an always-visible "nothing sends" * assurance, a progress rail, and the finish-line state when nothing is pending. */ @@ -70,7 +70,7 @@ describe("SingleActionQueue", () => { expect(screen.queryByText("Sample")).toBeNull(); }); - it("exposes Approve / Snooze / Skip controls and the nothing-sends assurance", () => { + it("exposes Approve / Not now / Hide controls and the nothing-sends assurance", () => { render( { />, ); expect(screen.getByRole("button", { name: /Approve: Do a/ })).toBeTruthy(); - expect(screen.getByRole("button", { name: /Snooze: Do a/ })).toBeTruthy(); - expect(screen.getByRole("button", { name: /Skip: Do a/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Not now.*Do a/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Hide.*Do a/ })).toBeTruthy(); expect(screen.getByTestId("single-action-queue").textContent).toMatch( /nothing sends without your approval/i, ); }); - it("maps Approve / Snooze / Skip to approved / skipped / dismissed", () => { + it("maps Approve / Not now / Hide to approved / skipped / dismissed", () => { const onDecision = vi.fn(); // Three independent renders so the same first card is present for each click // (the real parent re-supplies `actions` as decisions land). for (const [label, expected] of [ [/Approve: Do a/, "approved"], - [/Snooze: Do a/, "skipped"], - [/Skip: Do a/, "dismissed"], + [/Not now.*Do a/, "skipped"], + [/Hide.*Do a/, "dismissed"], ] as const) { cleanup(); render( diff --git a/app/(shell)/focus/FocusModeClient.tsx b/app/(shell)/focus/FocusModeClient.tsx index 54b3f5d8d..cebf513e5 100644 --- a/app/(shell)/focus/FocusModeClient.tsx +++ b/app/(shell)/focus/FocusModeClient.tsx @@ -6,13 +6,13 @@ import { Check, ChevronDown, ChevronRight, + Clock, Eye, Flame, LayoutDashboard, Loader2, Send, ShieldCheck, - SkipForward, Sparkles, Sun, TrendingUp, @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { useRouter } from "next/navigation"; import type { RecoveryOpportunity } from "@/lib/pulse/remote-client"; +import { ACTION_VERBS } from "@/lib/focus/action-verbs"; import type { BusinessVertical } from "@/lib/types"; import { getVerticalCopy, formatCurrency } from "@/lib/focus/vertical-copy"; import { useStreak, getStreakTier } from "@/lib/hooks/useStreak"; @@ -1192,7 +1193,7 @@ export function FocusModeClient({ {/* Swipe hint - only shows on first visit, mobile only */} {!simpleView && !Object.keys(decisions).length && pendingActions.length > 0 && (

- Swipe right to approve. Swipe left to skip for later. + Swipe right to approve. Swipe left for {ACTION_VERBS.skipped.label.toLowerCase()}.

)} @@ -1201,8 +1202,8 @@ export function FocusModeClient({

Tap Approve to send it (after your okay),{" "} - Snooze to revisit later, or{" "} - Skip to pass. + {ACTION_VERBS.skipped.label} to revisit later, or{" "} + {ACTION_VERBS.dismissed.label} to remove it. Nothing sends without your say-so.

diff --git a/app/(shell)/focus/WalkthroughBanner.tsx b/app/(shell)/focus/WalkthroughBanner.tsx index 405949667..9844854fc 100644 --- a/app/(shell)/focus/WalkthroughBanner.tsx +++ b/app/(shell)/focus/WalkthroughBanner.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { ChevronRight, X, Check, SkipForward, Eye, Sparkles } from "lucide-react"; import type { BusinessVertical } from "@/lib/types"; +import { ACTION_VERBS } from "@/lib/focus/action-verbs"; const STORAGE_KEY = "relay-walkthrough-complete"; @@ -21,8 +22,8 @@ function getSteps(clientNoun: string): WalkthroughStep[] { }, { icon: , - title: "Approve, skip, or dismiss", - description: "Tap Approve to send outreach on your behalf. Skip to revisit later. Dismiss to remove it. You stay in control. Nothing sends without your OK.", + title: `${ACTION_VERBS.approved.label}, ${ACTION_VERBS.skipped.label}, or ${ACTION_VERBS.dismissed.label}`, + description: `Tap ${ACTION_VERBS.approved.label} after reviewing. Each card tells you whether approval sends now or drafts for review. ${ACTION_VERBS.skipped.label} means it comes back later; ${ACTION_VERBS.dismissed.label} removes it. You stay in control.`, }, { icon: , diff --git a/components/focus/SingleActionQueue.tsx b/components/focus/SingleActionQueue.tsx index d228ae610..1e0dce7c1 100644 --- a/components/focus/SingleActionQueue.tsx +++ b/components/focus/SingleActionQueue.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; -import { Check, SkipForward, Clock, FlaskConical, ScrollText } from "lucide-react"; +import { Check, X, Clock, FlaskConical, ScrollText } from "lucide-react"; import type { RecoveryOpportunity } from "@/lib/pulse/remote-client"; +import { ACTION_VERBS } from "@/lib/focus/action-verbs"; import { TrustStrip, resolveProvenance, @@ -56,15 +57,15 @@ const EMPTY_FAILED_SET = new Set(); * * The page an owner lands on shows ONE action to take next, not a wall of panels. * This is the focused successor to the top-3 OneScreenBrief: it advances through the - * pending actions one card at a time (Approve / Snooze / Skip), with a progress rail + * pending actions one card at a time, with a progress rail * and an "N of M" counter, so the owner makes one decision, sees the next, and is * done. Per-card contract truth (source, status, "will not send until you approve") * stays visible via the shared TrustStrip. When every action is decided it shows the * finish-line state. * - * Decisions map to the existing 3-decision contract (approved / skipped / dismissed); - * "Snooze" is skipped (defer) and "Skip" is dismissed (not now) - identical mapping to - * OneScreenBrief so the backend decide endpoint is unchanged. + * Decisions map to the existing 3-decision contract (approved / skipped / dismissed). + * Labels come from ACTION_VERBS so every owner-facing surface uses the same + * words for the same consequence. * * Honesty (do not relax): * - Until the brief is built on the owner's live data, every card carries a visible @@ -120,7 +121,7 @@ export function SingleActionQueue({ return () => setAuditSubject(null); }, [currentSubject, setAuditSubject]); - // Screen-reader narrative: Approve/Snooze/Skip mutate the queue and advance the + // Screen-reader narrative: decisions mutate the queue and advance the // card, so a polite live region announces the decision + what's next. Without it // a non-sighted owner taps and hears nothing change (the #1 a11y gap on this surface). const [announcement, setAnnouncement] = useState(""); @@ -130,7 +131,11 @@ export function SingleActionQueue({ if (submitting.has(id) || localInFlightId.current === id) return; localInFlightId.current = id; const verb = - decision === "approved" ? "approval" : decision === "skipped" ? "snooze" : "skip"; + decision === "approved" + ? ACTION_VERBS.approved.label.toLowerCase() + : decision === "skipped" + ? ACTION_VERBS.skipped.label.toLowerCase() + : ACTION_VERBS.dismissed.label.toLowerCase(); setAnnouncement( `Saving ${verb}. Nothing sends until this is saved.`, ); @@ -143,7 +148,7 @@ export function SingleActionQueue({ [onDecision, submitting], ); - // Keyboard shortcuts for the morning ritual: A approve, S snooze, K skip. + // Keyboard shortcuts for the morning ritual: A approve, S not now, K hide. // The brief is a one-tap habit; on desktop the owner clears the queue without // reaching for the mouse. Ignored while a decision is in flight, when the // queue is empty, or when the owner is typing / has a control focused (input, @@ -330,28 +335,36 @@ export function SingleActionQueue({ Approve + {/* Answer the owner's scariest question AT her thumb, not three rows + up in the TrustStrip. Driven off the same canonical provenance.sends + boolean so it can never overclaim a send. */} +

+ {provenance.sends + ? `Approving sends this to ${current.target?.name ?? "your client"}.` + : "Approving drafts this for your review — nothing is sent yet."} +

@@ -364,10 +377,10 @@ export function SingleActionQueue({ approve · S{" "} - snooze + {ACTION_VERBS.skipped.label.toLowerCase()} · K{" "} - skip + {ACTION_VERBS.dismissed.label.toLowerCase()}

diff --git a/lib/focus/action-verbs.ts b/lib/focus/action-verbs.ts new file mode 100644 index 000000000..35b682fce --- /dev/null +++ b/lib/focus/action-verbs.ts @@ -0,0 +1,46 @@ +/** + * Canonical decision verbs for EVERY Focus / morning-brief action surface. + * + * Why this exists (a real, owner-trust bug it fixes): the word "Skip" did + * OPPOSITE things on the two screens an owner toggles between — + * - Simple Mode (SingleActionQueue): "Skip" -> dismissed (remove for good) + * - Full brief (FocusModeClient): "Skip" -> skipped (defer; comes back) + * So an owner who learned "Skip" = defer on one screen would, on the other, + * permanently drop a real client. For a wellness owner protective of her client + * relationships, not knowing whether she just deferred or dropped someone is the + * precise hesitation that turns into avoidance. One source of truth keeps the + * label -> consequence mapping identical on every surface. + * + * The three decisions map to the UNCHANGED backend contract; only the + * owner-facing labels are canonicalized here. + */ +export type ActionDecision = "approved" | "skipped" | "dismissed"; + +interface ActionVerb { + /** The button label — identical on every action surface. */ + label: string; + /** Plain-English consequence (use as aria detail / optional caption). */ + consequence: string; + /** Full aria-label verb phrase (label + consequence, for screen readers). */ + aria: string; +} + +export const ACTION_VERBS: Record = { + approved: { + label: "Approve", + consequence: "Approve this action.", + aria: "Approve", + }, + skipped: { + // Defer — it returns in a later brief. (Never "Skip": ambiguous with remove.) + label: "Not now", + consequence: "Comes back in a later brief.", + aria: "Not now (defer — it comes back in a later brief)", + }, + dismissed: { + // Remove for good. + label: "Hide", + consequence: "Removes this for good.", + aria: "Hide (remove this for good)", + }, +}; From e309b2cb1dcb9c81aea4d407fe75cb36f6ae6bdd Mon Sep 17 00:00:00 2001 From: "Victor \"David\" Medina" Date: Thu, 25 Jun 2026 16:31:21 -0400 Subject: [PATCH 2/2] test(focus): expect canonical approve save copy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/single-action-queue.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/single-action-queue.test.tsx b/__tests__/single-action-queue.test.tsx index 95b46b0ba..ab8dc6ce2 100644 --- a/__tests__/single-action-queue.test.tsx +++ b/__tests__/single-action-queue.test.tsx @@ -151,7 +151,7 @@ describe("SingleActionQueue", () => { expect(onDecision).toHaveBeenCalledWith("a", "approved"); expect(screen.getByText(/1 of 2/)).toBeTruthy(); - expect(screen.getByTestId("saq-announcer").textContent).toMatch(/Saving approval/i); + expect(screen.getByTestId("saq-announcer").textContent).toMatch(/Saving approve/i); expect(screen.getByTestId("saq-announcer").textContent).not.toMatch(/^Approved/i); });