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:
Michilis
2026-09-05 20:48:48 +00:00
co-authored by Claude Opus 5
parent 4c39926483
commit e4eb1617d1
59 changed files with 2148 additions and 67 deletions
+101 -26
View File
@@ -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')}
@@ -4,9 +4,11 @@ import { isApiError, type DeclarationDto } from '@impuestos/contracts';
import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { ConfettiMoment } from '@/components/confetti-moment';
import { FormPreview } from '@/components/form-preview';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
@@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const queryClient = useQueryClient();
const confirmDialog = useRef<HTMLDialogElement>(null);
const [staleWarning, setStaleWarning] = useState<string | null>(null);
const [celebrating, setCelebrating] = useState(false);
const declaration = useQuery({
queryKey: ['declarations', id],
@@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const approve = useMutation({
mutationFn: () => api.approveDeclaration(id),
onSuccess: async () => {
// One of the three moments FLOWS.md section 8 allows a celebration.
setCelebrating(true);
setStaleWarning(null);
confirmDialog.current?.close();
await queryClient.invalidateQueries({ queryKey: ['declarations'] });
@@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const officialTitle = `Formulario ${data.formCode}`;
return (
<div className="space-y-4 py-4 pb-28">
<Reveal className="space-y-4 py-4 pb-28">
<ConfettiMoment play={celebrating} />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
@@ -172,7 +179,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
</p>
) : null}
</div>
</Reveal>
);
}
@@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { CelebrationCanvas } from '@/components/canvas/celebration-canvas';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n/t';
@@ -46,8 +47,10 @@ export function FilingChecklist({
if (filed) {
return (
<Card className="space-y-2">
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */}
<Card className="relative isolate space-y-2 overflow-hidden">
{/* The second and last canvas spot (FLOWS.md D3), only in the moment it happens:
a reload of an already filed declaration is not a new success. */}
{markFiled.isSuccess ? <CelebrationCanvas /> : null}
<h2 className="text-xl font-semibold tracking-tight text-positive">
{t('decl.filed.title')}
</h2>
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Link, useRouter } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue';
import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr';
import { cn } from '@/lib/utils';
@@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) {
const [showNoQrHint, setShowNoQrHint] = useState(false);
const [torchOn, setTorchOn] = useState(false);
const [outcome, setOutcome] = useState<Outcome | null>(null);
// Whether anything of this reader's is still waiting to be sent. It follows the queue
// rather than being set once, so the notice goes away when the capture actually lands.
const [queued, setQueued] = useState(false);
useEffect(() => {
const check = () => void listQueue().then((items) => setQueued(items.length > 0));
const unsubscribe = subscribeToQueue(check);
void Promise.resolve().then(check);
return unsubscribe;
}, []);
const upload = useMutation({
mutationFn: async (input: { file: File; qrPayload: string | null }) => {
@@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) {
const submit = useCallback(
(file: File, qrPayload: string | null) => {
if (busy.current) return;
/**
* Offline, the capture is kept rather than lost (FLOWS.md B2). The chip in the shell
* sends it the moment there is a connection, so this screen can say so and move on.
*/
if (!navigator.onLine) {
void enqueueCapture(file, qrPayload);
return;
}
busy.current = true;
upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) });
},
@@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
</Card>
) : null}
{queued ? (
<p role="status" className="text-center text-sm text-[var(--text-muted)]">
{t('offline.queued')}
</p>
) : null}
{upload.isError ? (
<p role="alert" className="text-center text-sm text-overdue">
{t('common.error.generic')}
@@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
const nothingYet = !data.hasDocuments;
return (
<div className="space-y-4 py-4">
<Reveal className="space-y-4 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
@@ -89,7 +90,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
<InsightFeed insights={data.insights} locale={locale} />
</>
) : null}
</div>
</Reveal>
);
}
+4
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell';
import { LanguageSwitcher } from '@/components/language-switcher';
import { OfflineSync } from '@/components/offline-sync';
import { PwaProvider } from '@/components/pwa-provider';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -22,6 +24,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{/* Bottom padding clears the tab bar and the scan button above it. */}
<main className="flex-1 px-5 pb-36">{children}</main>
<AppShell />
<OfflineSync />
<PwaProvider />
</div>
);
}
@@ -3,6 +3,8 @@
import type { ProfileDto } from '@impuestos/contracts';
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PushOptIn } from '@/components/push-optin';
import { ThemeToggle } from '@/components/theme-toggle';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
@@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) {
</select>
</label>
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9).
They are wired with the notification channels in phase 4. */}
{/* Push shows itself only where it can work; Telegram is linked from the bot
rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */}
<PushOptIn />
</div>
) : null}
<ThemeToggle />
<div className="flex items-center justify-between gap-4 border-t pt-4">
<span className="text-sm">{t('common.language')}</span>
<select
@@ -8,6 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { DangerZone } from './danger-zone';
import { InstallPrompt } from '@/components/install-prompt';
import { Reveal } from '@/components/ui/reveal';
import { DependentsSection } from './dependents-section';
import { IdentitySection } from './identity-section';
import { NotificationsSection } from './notifications-section';
@@ -53,12 +55,13 @@ export function ProfileScreen() {
}
return (
<div className="space-y-4 py-4">
<Reveal className="space-y-4 py-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
<IdentitySection profile={profile.data} />
<DependentsSection />
<NotificationsSection profile={profile.data} />
<InstallPrompt />
<DangerZone />
</div>
</Reveal>
);
}