phase-7: motion, an installable app, and a capture that survives no signal
GSAP carries the counter roll-ups, the bandeja card physics, the dialog transitions and the three success moments FLOWS.md allows. Every one of them checks prefers-reduced-motion first and does nothing when it is set. boneyard and canvas-ui are not what SPEC.md's stack table says they are: on npm the names belong to two abandoned projects that do neither job. The skeletons were already ours; the two canvas spots are now sixty lines each with no dependency. DECISIONS.md records the substitution. The app installs, keeps a scan taken with no network in IndexedDB and sends it when there is one, falls back to a page that explains itself, and can push a deadline notice. Reading the log of what is queued is the source of truth, so the notice clears when the capture actually lands. The CSP now allows scripts by per-request nonce rather than by 'unsafe-inline'. That forced /offline to render per request: a prerendered page carries a build-time nonce no live policy matches, so its scripts were blocked and it never hydrated. Two crashes fixed on the way. web-push throws on a VAPID subject that is not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any machine with push keys died at boot; a misconfigured optional channel now switches itself off and says why. And a subscription the push service answers 410 for is deleted rather than retried forever. Lighthouse on the production build: accessibility 100, best practices 96, SEO 100, performance 73. The performance number is not trustworthy on this machine and DECISIONS.md says why; total blocking time did fall from 17.6s to 1.7s once the hero canvas stopped drawing at full resolution every frame and the landing page stopped importing GSAP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4c39926483
commit
e4eb1617d1
@@ -7,13 +7,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CategoryPicker } from '@/components/category-picker';
|
||||
import { ConfettiMoment } from '@/components/confetti-moment';
|
||||
import { Button, buttonClasses } from '@/components/ui/button';
|
||||
import { CheckDraw } from '@/components/ui/check-draw';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Past this many pixels, letting go commits the swipe. */
|
||||
@@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [drag, setDrag] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
// The drag lives on the element rather than in state: a re-render per pointer move is
|
||||
// both slower and a fight with GSAP over who owns the transform.
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const nextCard = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<number | null>(null);
|
||||
|
||||
const pending = useQuery({
|
||||
@@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed
|
||||
* document the moment worth celebrating, and it is only the first one: the count is read
|
||||
* before the confirm so the answer is about what the user had, not what they now have.
|
||||
*/
|
||||
const confirmed = useQuery({
|
||||
queryKey: ['documents', { status: 'confirmed' }],
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal),
|
||||
});
|
||||
const nothingConfirmedYet = confirmed.data?.total === 0;
|
||||
|
||||
const documents = pending.data?.items ?? [];
|
||||
// Confirming the last card shortens the list under us. Clamping here rather than in an
|
||||
// effect keeps the render consistent and avoids a cascading update.
|
||||
const safeIndex = Math.min(index, Math.max(0, documents.length - 1));
|
||||
const current = documents[safeIndex];
|
||||
const next = documents[safeIndex + 1];
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||
@@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const confirm = useMutation({
|
||||
mutationFn: (id: string) => api.confirmDocument(id),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
if (nothingConfirmedYet) setCelebrating(true);
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
@@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const reject = useMutation({
|
||||
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
|
||||
api.rejectDocument(args.id, { reason: args.reason }),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
await invalidate();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const reclassify = useMutation({
|
||||
@@ -74,14 +91,55 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends the card off the way it was pushed, brings the one behind it forward, and only
|
||||
* then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under
|
||||
* reduced motion the action happens immediately and nothing moves.
|
||||
*/
|
||||
const flyOut = useCallback(
|
||||
(direction: 1 | -1, run: () => void) => {
|
||||
const node = card.current;
|
||||
if (reduced || !node) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
gsap.to(node, {
|
||||
x: direction * 520,
|
||||
rotation: direction * 16,
|
||||
autoAlpha: 0,
|
||||
duration: DURATION.base,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
gsap.set(node, { clearProps: 'all' });
|
||||
run();
|
||||
},
|
||||
});
|
||||
if (nextCard.current) {
|
||||
gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE });
|
||||
}
|
||||
},
|
||||
[reduced],
|
||||
);
|
||||
|
||||
const settle = useCallback(() => {
|
||||
const node = card.current;
|
||||
if (!node) return;
|
||||
if (reduced) {
|
||||
gsap.set(node, { x: 0, rotation: 0 });
|
||||
return;
|
||||
}
|
||||
gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE });
|
||||
}, [reduced]);
|
||||
|
||||
// Desktop keyboard mirrors of every gesture (FLOWS.md B4).
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!current || picking) return;
|
||||
if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1));
|
||||
else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0));
|
||||
else if (event.key === 'Enter') confirm.mutate(current.id);
|
||||
else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' });
|
||||
else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (event.key === 'x' || event.key === 'X')
|
||||
flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }));
|
||||
else if (/^[1-8]$/.test(event.key)) {
|
||||
const category = IRP_CATEGORIES[Number(event.key) - 1];
|
||||
if (category) reclassify.mutate({ id: current.id, irpCategory: category });
|
||||
@@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]);
|
||||
|
||||
if (pending.isPending) {
|
||||
return (
|
||||
@@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
<div className="py-4">
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Check aria-hidden className="size-8" />}
|
||||
icon={<CheckDraw className="size-8" />}
|
||||
title={t('bandeja.empty')}
|
||||
body={t('bandeja.emptyBody')}
|
||||
action={
|
||||
@@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<ConfettiMoment play={celebrating} />
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1>
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/*
|
||||
The shoulder of the next card, so the stack is visible and committing this one
|
||||
has somewhere to go. A shoulder rather than a whole card behind: the front card's
|
||||
height depends on its content, and a full card would sit entirely hidden behind a
|
||||
tall one and entirely exposed behind a short one.
|
||||
*/}
|
||||
{next ? (
|
||||
<div
|
||||
ref={nextCard}
|
||||
aria-hidden
|
||||
className="absolute inset-x-4 -top-2 h-8 origin-bottom scale-95 rounded-2xl border bg-[var(--surface-raised)] opacity-70"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
ref={card}
|
||||
data-testid="bandeja-card"
|
||||
className="touch-pan-y space-y-4 select-none"
|
||||
style={{
|
||||
transform: `translateX(${drag}px) rotate(${drag / 40}deg)`,
|
||||
transition: dragging ? 'none' : 'transform 200ms ease-out',
|
||||
}}
|
||||
className="relative touch-pan-y space-y-4 select-none"
|
||||
onPointerDown={(event) => {
|
||||
dragStart.current = event.clientX;
|
||||
setDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (dragStart.current === null) return;
|
||||
setDrag(event.clientX - dragStart.current);
|
||||
const offset = event.clientX - dragStart.current;
|
||||
gsap.set(card.current, { x: offset, rotation: offset / 40 });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const offset = drag;
|
||||
onPointerUp={(event) => {
|
||||
const start = dragStart.current;
|
||||
dragStart.current = null;
|
||||
setDragging(false);
|
||||
if (offset > COMMIT_PX) confirm.mutate(current.id);
|
||||
if (start === null) return;
|
||||
const offset = event.clientX - start;
|
||||
|
||||
if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (offset < -COMMIT_PX) {
|
||||
settle();
|
||||
setPicking(true);
|
||||
setDrag(0);
|
||||
} else setDrag(0);
|
||||
} else settle();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{picking ? (
|
||||
<Card className="space-y-3">
|
||||
@@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
size="lg"
|
||||
block
|
||||
disabled={confirm.isPending}
|
||||
onClick={() => confirm.mutate(current.id)}
|
||||
onClick={() => flyOut(1, () => confirm.mutate(current.id))}
|
||||
>
|
||||
<Check aria-hidden className="size-4" />
|
||||
{t('bandeja.confirm')}
|
||||
@@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={reject.isPending}
|
||||
onClick={() => reject.mutate({ id: current.id, reason: 'other' })}
|
||||
onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))}
|
||||
>
|
||||
<X aria-hidden className="size-4" />
|
||||
{t('scan.result.discard')}
|
||||
|
||||
Reference in New Issue
Block a user