Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions __tests__/single-action-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
<SingleActionQueue
actions={[opp("a")]}
Expand All @@ -80,21 +80,21 @@ describe("SingleActionQueue", () => {
/>,
);
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(
Expand Down
15 changes: 8 additions & 7 deletions app/(shell)/focus/FocusModeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ import {
Check,
ChevronDown,
ChevronRight,
Clock,
Eye,
Flame,
LayoutDashboard,
Loader2,
Send,
ShieldCheck,
SkipForward,
Sparkles,
Sun,
TrendingUp,
X,
} 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";
Expand Down Expand Up @@ -1192,7 +1193,7 @@ export function FocusModeClient({
{/* Swipe hint - only shows on first visit, mobile only */}
{!simpleView && !Object.keys(decisions).length && pendingActions.length > 0 && (
<p className="mb-3 text-center text-[11px] text-stone-400 md:hidden">
Swipe right to approve. Swipe left to skip for later.
Swipe right to approve. Swipe left for {ACTION_VERBS.skipped.label.toLowerCase()}.
</p>
)}

Expand All @@ -1201,8 +1202,8 @@ export function FocusModeClient({
<div className="mb-4 flex items-start gap-3 rounded-xl border border-stone-700 border-l-amber-500 border-l-2 bg-stone-800/60 px-3 py-2.5 animate-fade-in">
<p className="flex-1 text-xs text-stone-400 leading-relaxed">
Tap <span className="font-medium text-stone-300">Approve</span> to send it (after your okay),{" "}
<span className="font-medium text-stone-300">Snooze</span> to revisit later, or{" "}
<span className="font-medium text-stone-300">Skip</span> to pass.
<span className="font-medium text-stone-300">{ACTION_VERBS.skipped.label}</span> to revisit later, or{" "}
<span className="font-medium text-stone-300">{ACTION_VERBS.dismissed.label}</span> to remove it.
Nothing sends without your say-so.
</p>
<button
Expand Down Expand Up @@ -1492,16 +1493,16 @@ export function FocusModeClient({
disabled={submitting.has(action.id)}
className="flex items-center justify-center gap-1 rounded-lg bg-stone-700/60 border border-stone-600/50 px-3 py-2.5 text-sm text-stone-200 hover:bg-stone-700 hover:text-white transition-colors min-h-11 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900"
>
<SkipForward className="h-3.5 w-3.5" />
Skip
<Clock className="h-3.5 w-3.5" />
{ACTION_VERBS.skipped.label}
</button>
<button
onClick={() => handleDecision(action.id, "dismissed")}
disabled={submitting.has(action.id)}
className="flex items-center justify-center gap-1 rounded-lg px-3 py-2.5 text-sm text-stone-400 hover:text-stone-200 hover:bg-stone-800/50 transition-colors min-h-11 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900"
>
<X className="h-3.5 w-3.5" />
Dismiss
{ACTION_VERBS.dismissed.label}
</button>
</div>

Expand Down
5 changes: 3 additions & 2 deletions app/(shell)/focus/WalkthroughBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,8 +22,8 @@ function getSteps(clientNoun: string): WalkthroughStep[] {
},
{
icon: <Check className="h-4 w-4 text-teal" />,
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: <Eye className="h-4 w-4 text-amber-400" />,
Expand Down
21 changes: 15 additions & 6 deletions components/focus/SingleActionQueue.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -330,28 +331,36 @@ export function SingleActionQueue({
<Check className="h-4 w-4" aria-hidden />
Approve
</button>
{/* 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. */}
<p className="text-center text-xs text-stone-400">
{provenance.sends
? `Approving sends this to ${current.target?.name ?? "your client"}.`
: "Approving drafts this for your review — nothing is sent yet."}
</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
aria-label={`Snooze: ${headline}`}
aria-label={`${ACTION_VERBS.skipped.aria}: ${headline}`}
aria-keyshortcuts="s"
disabled={busy}
onClick={() => decide(current.id, "skipped")}
className="inline-flex min-h-11 items-center justify-center gap-1 rounded-lg border border-stone-600/50 bg-stone-700/60 px-3 py-2.5 text-sm text-stone-200 transition-colors hover:bg-stone-700 hover:text-white disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900"
>
<Clock className="h-3.5 w-3.5" aria-hidden />
Snooze
{ACTION_VERBS.skipped.label}
</button>
<button
type="button"
aria-label={`Skip: ${headline}`}
aria-label={`${ACTION_VERBS.dismissed.aria}: ${headline}`}
aria-keyshortcuts="k"
disabled={busy}
onClick={() => decide(current.id, "dismissed")}
className="inline-flex min-h-11 items-center justify-center gap-1 rounded-lg px-3 py-2.5 text-sm text-stone-400 transition-colors hover:bg-stone-800/50 hover:text-stone-200 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900"
>
<SkipForward className="h-3.5 w-3.5" aria-hidden />
Skip
<X className="h-3.5 w-3.5" aria-hidden />
{ACTION_VERBS.dismissed.label}
</button>
</div>
</div>
Expand Down
46 changes: 46 additions & 0 deletions lib/focus/action-verbs.ts
Original file line number Diff line number Diff line change
@@ -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<ActionDecision, ActionVerb> = {
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)",
},
};