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
16 changes: 8 additions & 8 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 Expand Up @@ -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);
});

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} 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: <Eye className="h-4 w-4 text-amber-400" />,
Expand Down
43 changes: 28 additions & 15 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 @@ -56,15 +57,15 @@ const EMPTY_FAILED_SET = new Set<string>();
*
* 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
Expand Down Expand Up @@ -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("");
Expand All @@ -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.`,
);
Expand All @@ -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,
Expand Down Expand Up @@ -330,28 +335,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 All @@ -364,10 +377,10 @@ export function SingleActionQueue({
approve
<span className="px-1 text-stone-600">·</span>
<kbd className="rounded border border-stone-600 bg-stone-800 px-1 font-sans text-stone-300">S</kbd>{" "}
snooze
{ACTION_VERBS.skipped.label.toLowerCase()}
<span className="px-1 text-stone-600">·</span>
<kbd className="rounded border border-stone-600 bg-stone-800 px-1 font-sans text-stone-300">K</kbd>{" "}
skip
{ACTION_VERBS.dismissed.label.toLowerCase()}
</p>
</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)",
},
};