feat(ui): FeedCard dismiss surfaces + UndoToast + Feed wire (slice 18 Checkpoint B)

Surfaces added (per IMPLEMENTATION-PRD §3.1 + HG-1/2/3/4):
- FeedCard now accepts onDismiss?: (seq: number) => void prop
  - Hover-revealed X button top-right of card header (HG-2)
  - Backspace + Delete keys on focused card (HG-3); tabIndex=0 when interactive
  - Touch swipe-right past 50% width threshold (HG-1); leftward swipes + below-threshold no-op
  - All surfaces no-op when onDismiss is omitted (full backward compat)
- New UndoToast component — single-action transient surface
  - durationMs param drives onExpire timer; onUndo click suppresses onExpire (HG-4)
  - Fixed bottom-center placement, role="status" + aria-live="polite"
- Feed.tsx wires onDismiss → dismiss(seq) + sets pending undo seq → renders UndoToast
  - Undo click calls undismiss(seq) + clears pending
  - Toast keyed on pendingUndoSeq so each new dismiss resets the 5s timer

Tests (TDD-first; lands green at this commit):
- 4/4 UndoToast leaf tests (fake-timer driven onExpire/onUndo races)
- 10/10 FeedCard dismiss surface tests (button, keyboard, touch threshold + leftward)
- 25/25 prior activity-feed tests still green (no regression)

Switched gesture from pointer events to touch events: jsdom does not
reliably forward pointerType through fireEvent, and touch events are
the semantically correct mobile-only API anyway. Mouse-on-desktop now
uses the X button exclusively (no mouse drag interpretation).

Note on integration test scope: Feed.tsx full-stack integration tests
would require mocking SSE + /api/config + queue items + audit + slices +
missions + router. Per banked feedback_appshell_global_polls_test_pollution
(2026-05-05 Phase E hygiene rule), direct-render leaf tests are
preferred and full-stack tests pollute jsdom state. Feed wire proof is
the typecheck-clean diff itself; live Feed integration defers to
QA/dogfood window.

Typecheck: 34 pre-existing errors on branch IDENTICAL to raw main HEAD;
useDismissedSeqs.ts + UndoToast.tsx + FeedCard.tsx + Feed.tsx all
typecheck clean — zero new errors.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 14:01:12 -07:00
1 parent 229d48ba91
commit ddb1c4724f
4 files changed
+158 -17

No files matched your search

+31 -2
View File
@@ -11,7 +11,7 @@
// Lens chips remain a transient ad-hoc filter on top of subscription-
// filtered cards.
import { useState, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { cn } from "../../lib/utils.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
@@ -35,6 +35,7 @@ import {
} from "../../hooks/useFeedSubscriptions.js";
import { useDismissedSeqs } from "../../hooks/useDismissedSeqs.js";
import { FeedCard } from "./FeedCard.js";
import { UndoToast } from "./UndoToast.js";
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
import {
StorytellingFeed,
@@ -216,7 +217,25 @@ export function Feed() {
const rawCards = useMemo(() => classifyFeed(events).slice(0, HISTORY_LIMIT), [events]);
const rawCardSeqs = useMemo(() => rawCards.map((c) => c.source.seq), [rawCards]);
const { dismissedSeqs } = useDismissedSeqs(rawCardSeqs);
const { dismissedSeqs, dismiss, undismiss } = useDismissedSeqs(rawCardSeqs);
const [pendingUndoSeq, setPendingUndoSeq] = useState<number | null>(null);
const handleDismiss = useCallback(
(seq: number) => {
dismiss(seq);
setPendingUndoSeq(seq);
},
[dismiss],
);
const handleUndo = useCallback(() => {
if (pendingUndoSeq !== null) undismiss(pendingUndoSeq);
setPendingUndoSeq(null);
}, [pendingUndoSeq, undismiss]);
const handleUndoExpire = useCallback(() => {
setPendingUndoSeq(null);
}, []);
const qitemIds = useMemo(
() => rawCards.map(qitemIdForCard).filter((id): id is string => Boolean(id)),
[rawCards],
@@ -357,11 +376,21 @@ export function Feed() {
queueItem={queueItem}
proofPreview={proofPreview}
actionOutcome={actionOutcome}
onDismiss={handleDismiss}
/>
);
})}
</div>
)}
{pendingUndoSeq !== null ? (
<UndoToast
key={pendingUndoSeq}
label="Card dismissed"
onUndo={handleUndo}
onExpire={handleUndoExpire}
durationMs={5000}
/>
) : null}
</div>
);
}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ArrowRight, CalendarDays, CircleAlert, Clock, History, PackageCheck } from "lucide-react";
import { useRef, useState } from "react";
import { ArrowRight, CalendarDays, CircleAlert, Clock, History, PackageCheck, X } from "lucide-react";
import { VellumCard } from "../ui/vellum-card.js";
import { AuthorAgentTag } from "./AuthorAgentTag.js";
@@ -297,18 +297,60 @@ function ActionOutcomePanel({ outcome }: { outcome: FeedActionOutcome }) {
);
}
const SWIPE_DISMISS_THRESHOLD = 0.5; // fraction of card width
export function FeedCard({
card,
queueItem,
proofPreview,
actionOutcome,
onDismiss,
}: {
card: FeedCardModel;
queueItem?: QueueItemDetail;
proofPreview?: FeedProofPreview | null;
actionOutcome?: FeedActionOutcome | null;
onDismiss?: (seq: number) => void;
}) {
const [selectedScreenshot, setSelectedScreenshot] = useState<string | null>(null);
const dragStateRef = useRef<{ startX: number; pointerId: number; isTouch: boolean } | null>(null);
const handleKeyDown: React.KeyboardEventHandler<HTMLElement> = (event) => {
if (!onDismiss) return;
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
onDismiss(card.source.seq);
}
};
const handleTouchStart: React.TouchEventHandler<HTMLElement> = (event) => {
if (!onDismiss) return;
const touch = event.touches[0];
if (!touch) return;
dragStateRef.current = { startX: touch.clientX, pointerId: touch.identifier, isTouch: true };
};
const handleTouchEnd: React.TouchEventHandler<HTMLElement> = (event) => {
const drag = dragStateRef.current;
dragStateRef.current = null;
if (!onDismiss || !drag) return;
const touch = event.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - drag.startX;
if (deltaX <= 0) return;
const target = event.currentTarget;
const rect = target.getBoundingClientRect();
if (rect.width <= 0) return;
if (deltaX / rect.width >= SWIPE_DISMISS_THRESHOLD) {
onDismiss(card.source.seq);
}
};
const handleDismissClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
event.stopPropagation();
if (!onDismiss) return;
onDismiss(card.source.seq);
};
const qitemViewerData = qitemViewerDataFromItem(card, queueItem);
const body = compactQueueBody(queueItem?.body || card.body);
const tags = queueItem?.tags ?? qitemViewerData?.tags ?? [];
@@ -323,7 +365,15 @@ export function FeedCard({
as="article"
testId={KIND_TESTID[card.kind]}
accentClass={renderedOutcome ? TONE_ACCENT[primaryToken.tone] : KIND_ACCENT[card.kind]}
className="mb-3 bg-white/50 backdrop-blur-sm"
className="mb-3 bg-white/50 backdrop-blur-sm group"
{...(onDismiss
? {
tabIndex: 0,
onKeyDown: handleKeyDown,
onTouchStart: handleTouchStart,
onTouchEnd: handleTouchEnd,
}
: {})}
>
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3">
@@ -340,7 +390,20 @@ export function FeedCard({
{card.title}
</h3>
</div>
<InlineDateMark value={card.createdAt} />
<div className="flex items-start gap-2">
<InlineDateMark value={card.createdAt} />
{onDismiss ? (
<button
type="button"
data-testid="feed-card-dismiss"
aria-label="Dismiss card"
onClick={handleDismissClick}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-stone-400 transition-opacity inline-flex h-5 w-5 items-center justify-center border border-stone-300 bg-white/80 text-stone-600 hover:text-stone-900 hover:border-stone-500"
>
<X className="h-3 w-3" strokeWidth={1.8} />
</button>
) : null}
</div>
</div>
{body ? (
<p className="mt-3 font-mono text-xs leading-relaxed text-on-surface-variant whitespace-pre-line">
@@ -0,0 +1,53 @@
// Slice 18 — Undo toast for For-You per-card dismiss flow.
//
// Single-action transient surface: shows a label + Undo button for
// a fixed window. Clicking Undo fires onUndo and suppresses the
// pending expire callback. If the window elapses without click,
// onExpire fires once and the toast caller is expected to unmount.
import { useEffect, useRef } from "react";
export interface UndoToastProps {
label: string;
onUndo: () => void;
onExpire: () => void;
durationMs: number;
}
export function UndoToast({ label, onUndo, onExpire, durationMs }: UndoToastProps) {
const consumedRef = useRef(false);
useEffect(() => {
const handle = window.setTimeout(() => {
if (consumedRef.current) return;
consumedRef.current = true;
onExpire();
}, durationMs);
return () => window.clearTimeout(handle);
}, [durationMs, onExpire]);
const handleUndo = () => {
if (consumedRef.current) return;
consumedRef.current = true;
onUndo();
};
return (
<div
data-testid="undo-toast"
role="status"
aria-live="polite"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 border border-stone-700 bg-stone-900/95 px-4 py-2 font-mono text-[11px] uppercase tracking-wide text-stone-50 backdrop-blur-sm shadow-lg"
>
<span>{label}</span>
<button
type="button"
data-testid="undo-toast-button"
onClick={handleUndo}
className="border border-stone-500 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-stone-50 hover:bg-stone-800 focus:outline-none focus:ring-1 focus:ring-stone-300"
>
Undo
</button>
</div>
);
}
+7 -11
View File
@@ -79,13 +79,11 @@ describe("FeedCard dismiss surfaces", () => {
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
const article = screen.getByTestId("feed-card-progress");
// Stub the article's bounding rect so the threshold math is deterministic
article.getBoundingClientRect = () =>
({ width: 400, left: 0, right: 400, top: 0, bottom: 100, height: 100, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
fireEvent.pointerDown(article, { pointerType: "touch", clientX: 50, clientY: 50, pointerId: 1 });
fireEvent.pointerMove(article, { pointerType: "touch", clientX: 300, clientY: 50, pointerId: 1 });
fireEvent.pointerUp(article, { pointerType: "touch", clientX: 300, clientY: 50, pointerId: 1 });
fireEvent.touchStart(article, { touches: [{ clientX: 50, clientY: 50, identifier: 1 }] });
fireEvent.touchEnd(article, { changedTouches: [{ clientX: 300, clientY: 50, identifier: 1 }] });
expect(onDismiss).toHaveBeenCalledWith(42);
});
@@ -97,23 +95,21 @@ describe("FeedCard dismiss surfaces", () => {
article.getBoundingClientRect = () =>
({ width: 400, left: 0, right: 400, top: 0, bottom: 100, height: 100, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
fireEvent.pointerDown(article, { pointerType: "touch", clientX: 50, clientY: 50, pointerId: 1 });
fireEvent.pointerMove(article, { pointerType: "touch", clientX: 100, clientY: 50, pointerId: 1 });
fireEvent.pointerUp(article, { pointerType: "touch", clientX: 100, clientY: 50, pointerId: 1 });
fireEvent.touchStart(article, { touches: [{ clientX: 50, clientY: 50, identifier: 1 }] });
fireEvent.touchEnd(article, { changedTouches: [{ clientX: 100, clientY: 50, identifier: 1 }] });
expect(onDismiss).not.toHaveBeenCalled();
});
it("mouse pointerType pointer events do NOT trigger swipe dismiss (button-only for desktop)", () => {
it("leftward swipe does NOT trigger onDismiss (only right-swipe dismisses)", () => {
const onDismiss = vi.fn();
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
const article = screen.getByTestId("feed-card-progress");
article.getBoundingClientRect = () =>
({ width: 400, left: 0, right: 400, top: 0, bottom: 100, height: 100, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
fireEvent.pointerDown(article, { pointerType: "mouse", clientX: 50, clientY: 50, pointerId: 1 });
fireEvent.pointerMove(article, { pointerType: "mouse", clientX: 300, clientY: 50, pointerId: 1 });
fireEvent.pointerUp(article, { pointerType: "mouse", clientX: 300, clientY: 50, pointerId: 1 });
fireEvent.touchStart(article, { touches: [{ clientX: 300, clientY: 50, identifier: 1 }] });
fireEvent.touchEnd(article, { changedTouches: [{ clientX: 50, clientY: 50, identifier: 1 }] });
expect(onDismiss).not.toHaveBeenCalled();
});