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..02b7fc630 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} and Relay sends the outreach for you — never before you approve. ${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..c848e632e 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, @@ -330,28 +331,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."} +

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)", + }, +};