mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
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:
1 parent
229d48ba91
commit
ddb1c4724f
4 files changed
+158
-17
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user