Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e164ecf
E2E: what the contact and paperwork forms expose when a submit fails …
claude Oct 1, 2026
e006233
E2E: schedule the social post a week ahead, not on a fixed date
claude Oct 1, 2026
b5d75c8
Paperwork composer: say a refusal in the reader's words; refuse a bla…
claude Oct 1, 2026
361a971
Paperwork composer: keep keyboard focus through a refused or saved pa…
claude Oct 1, 2026
65b6cf2
Paperwork composer: only hand focus back while the saving composer ho…
claude Oct 1, 2026
915eb15
Paperwork composer: keep a refused paste; a late save closes only its…
claude Oct 1, 2026
81ba4dc
E2E: say why the Plus-plan setup failed; retry it once only when noth…
claude Oct 2, 2026
7c2bb3e
Merge main (d25e39ea) into claude/form-a11y-etzqg7
claude Oct 2, 2026
bd4b711
Contact form: a refused save keeps keyboard focus on its submit (A11Y…
claude Oct 2, 2026
52b30a8
Paperwork spec: start each case once the page's own server actions se…
claude Oct 2, 2026
d671e2d
Mobile audit: accept node-forge GHSA-86w9-cpqp-85rv by id until it is…
claude Oct 2, 2026
0afe697
Contacts spec: the held write's wait ends and names why it was not sent
claude Oct 2, 2026
8dcd67b
Toasts sit bottom-right and at most 14rem wide on desktop (A11Y-001 #2)
claude Oct 3, 2026
d316a6b
Merge origin/main into claude/form-a11y-etzqg7
claude Oct 3, 2026
62248b4
Merge origin/main into claude/form-a11y-etzqg7
claude Oct 3, 2026
736e1ff
Characterize the toast stack over the corner buttons (A11Y-001 #2)
claude Oct 3, 2026
1988389
Merge origin/main into claude/form-a11y-etzqg7
claude Oct 3, 2026
b5bf755
Lift the toast stack above the corner controls from lg (A11Y-001 #2)
claude Oct 3, 2026
0f6f495
Corner cases: compare who takes the tap, not which part of the notice
claude Oct 3, 2026
03eecde
Corner cases: the blocked invariant made explicit, with a moved-clear…
claude Oct 3, 2026
f3988cc
Below lg, the toast stack stops left of the corner buttons (A11Y-001 #2)
claude Oct 3, 2026
d3b3a51
Corner cases at 360x780: the residual recorded as it is (A11Y-001 #2)
claude Oct 3, 2026
93c306f
Below lg, the toast stack's right inset is 4.75rem, not 5rem (A11Y-00…
claude Oct 3, 2026
dd22990
Assistant: the welcome composer either side of lg, recorded (new spec)
claude Oct 3, 2026
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
23 changes: 20 additions & 3 deletions components/modules/contacts-module.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client';

import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import {
Users, Plus, Phone, Mail, MapPin, Star, Trash2, Edit2,
Search, User, Stethoscope, GraduationCap, Trophy, Home,
AlertTriangle, HeartPulse, Smile, Briefcase, ChevronRight,
X, Copy, ExternalLink,
X, Copy, ExternalLink, Loader2,
} from 'lucide-react';
import { useApp } from '@/components/app/app-context';
import { useRealtimeQuery } from '@/lib/hooks/use-realtime-query';
Expand Down Expand Up @@ -425,13 +425,18 @@ function ContactModal({ contact, familyId, userId, onClose, onSaved }: {
const format = useFormat();
const { success, error: toastError } = useToast();
const [loading, setLoading] = useState(false);
// Refuses a second submit while one is in flight. The submit stays
// focusable (aria-disabled) while saving, so this, not `disabled`, is what
// stops a repeated Enter (A11Y-001).
const submitting = useRef(false);
const months = useMemo(() => {
// Month names from DATES, which the shared formatter renders as written.
return Array.from({ length: 12 }, (_, month) => format.fmtDate(`2000-${String(month + 1).padStart(2, '0')}-01`, 'MMMM'));
}, [format]);

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (submitting.current) return;
const form = new FormData(e.currentTarget);
const payload = contactPayload(form);
// ── Validation ──
Expand All @@ -442,6 +447,7 @@ function ContactModal({ contact, familyId, userId, onClose, onSaved }: {
if (payload.phone_alt && !isValidPhone(payload.phone_alt)) return toastError(t('contactsModule.theAlternatePhoneNumberLooks'));
if (payload.birthday_day != null && (payload.birthday_day < 1 || payload.birthday_day > 31)) return toastError(t('contactsModule.birthdayDayMustBeBetween'));

submitting.current = true;
setLoading(true);
try {
const supabase = createClient();
Expand All @@ -456,6 +462,7 @@ function ContactModal({ contact, familyId, userId, onClose, onSaved }: {
} catch (err) {
toastError(describeDbError(err));
} finally {
submitting.current = false;
setLoading(false);
}
}
Expand Down Expand Up @@ -548,7 +555,17 @@ function ContactModal({ contact, familyId, userId, onClose, onSaved }: {

<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>{t('contacts.cancel')}</Button>
<Button type="submit" loading={loading}>{t(contact ? 'family.saveChanges' : 'contacts.addContact')}</Button>
{/* aria-disabled, not the shared Button's `loading` (which sets
`disabled`): a natively disabled submit drops the keyboard focus
that pressed it to <body>, outside this dialog. */}
<Button
type="submit"
aria-disabled={loading || undefined}
className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:shadow-none"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{t(contact ? 'family.saveChanges' : 'contacts.addContact')}
</Button>
</div>
</form>
</Modal>
Expand Down
78 changes: 64 additions & 14 deletions components/modules/paperwork-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Paperwork Inbox — mobile-first triage for family paperwork. Status filter
// chips, urgency-ranked cards with the AI-extracted action items, one-tap
// materialization (calendar event / reminder), and a paste-to-capture composer.
import { useMemo, useState, useTransition } from 'react';
import { useMemo, useRef, useState, useTransition } from 'react';
import {
Inbox, FileSignature, School, Stethoscope, Trophy, Receipt, PartyPopper,
FileText, Plus, Check, CalendarPlus, BellPlus, Archive, RotateCcw, CalendarCheck,
Expand All @@ -19,6 +19,7 @@ import {
} from '@/app/(app)/dashboard/paperwork/actions';
import { useToast } from '@/components/ui/toast';
import { reportRefusal } from '@/lib/auth/step-up-client';
import { refusalForThrown } from '@/lib/actions/refusal';
import { cn } from '@/lib/utils/cn';
import { useLocale, useTranslations } from '@/components/i18n/locale-provider';
import { DocumentCapture } from '@/components/capture/document-capture';
Expand Down Expand Up @@ -70,6 +71,7 @@ export function PaperworkModule({ items }: { items: Item[] }) {
const router = useRouter();
const [filter, setFilter] = useState<(typeof FILTERS)[number]['key']>('needs_action');
const [composerOpen, setComposerOpen] = useState(false);
const composerToggle = useRef<HTMLButtonElement>(null);
const [pending, startTransition] = useTransition();
const [busyKey, setBusyKey] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -151,6 +153,7 @@ export function PaperworkModule({ items }: { items: Item[] }) {
<p className="mt-1 max-w-xl text-sm text-muted">{t('paperworkModule.pasteAnySlipFormOr')}</p>
</div>
<button
ref={composerToggle}
onClick={() => setComposerOpen((v) => !v)}
className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand px-4 text-sm font-bold text-brand-fg transition hover:opacity-90"
>
Expand All @@ -163,7 +166,12 @@ export function PaperworkModule({ items }: { items: Item[] }) {
{composerOpen && <div className="mt-4 space-y-4">
<DocumentCapture onSaved={() => { setFilter('needs_action'); router.refresh(); }} />
<a className="my-3 block text-sm text-brand-text underline" href="/capture/link">{t('documentLink.title')}</a>
<Composer onDone={() => setComposerOpen(false)} />
{/* A save closes the composer around the focused submit; hand focus to
the toggle that opened it rather than letting it fall to <body> —
but only while that composer still holds it, so a late save never
pulls focus from wherever the reader has gone since. A save from a
composer already closed does neither: it must not close a new one. */}
<Composer onDone={(ownsFocus) => { setComposerOpen(false); if (ownsFocus) composerToggle.current?.focus(); }} />
</div>}

{/* Filter chips */}
Expand Down Expand Up @@ -339,23 +347,63 @@ export function PaperworkModule({ items }: { items: Item[] }) {
);
}

function Composer({ onDone }: { onDone: () => void }) {
function Composer({ onDone }: { onDone: (ownsFocus: boolean) => void }) {
const t = useTranslations();
const { error: toastError } = useToast();
const [pending, startTransition] = useTransition();
// Refuses a second submit while one is in flight: the submit stays focusable
// (aria-disabled, not disabled), so it no longer blocks one natively.
const submitting = useRef(false);
const form = useRef<HTMLFormElement>(null);
// Whether this composer still holds the focus: it is mounted and focus is
// inside it, or has fallen to <body> as its submit went away.
const ownsFocus = () => {
const active = document.activeElement;
return !!form.current?.isConnected && (!active || active === document.body || form.current.contains(active));
};
// A save closes only the composer that sent it, never one opened since.
const saved = () => { if (form.current?.isConnected) onDone(ownsFocus()); };

return (
<form
action={(fd) => startTransition(async () => {
// A throw here used to leave the form open with nothing said; a
// step-up refusal is answered, not thrown, and goes to the code page.
try {
const res = await addPaperworkAction(fd);
if (!res.ok) { reportRefusal(res, toastError); return; }
onDone();
ref={form}
onSubmit={(e) => {
// Submitted here rather than through `action`: React resets a form's
// fields when its action settles, refused or not, which wiped a pasted
// letter the server had just refused. The composer closes on success,
// so what was typed only stays when there is a reason to keep it.
e.preventDefault();
if (submitting.current) return;
// Native `required` accepts a paste of only spaces, which the action
// trims to nothing and answers ok without saving. Treat it as the empty
// paste it is, so the browser refuses it the same way.
const field = e.currentTarget.elements.namedItem('text');
if (field instanceof HTMLTextAreaElement && !field.value.trim()) {
field.value = '';
field.reportValidity();
return;
}
catch (err) { toastError(err instanceof Error && err.message ? err.message : t('globalError.somethingWentWrong')); }
})}
const fd = new FormData(e.currentTarget);
startTransition(async () => {
submitting.current = true;
// A throw here used to leave the form open with nothing said; a
// step-up refusal is answered, not thrown, and goes to the code page.
// Production redacts a thrown action's message, so it is read with
// refusalForThrown, as useActionError does.
try {
const res = await addPaperworkAction(fd);
if (!res.ok) { reportRefusal(res, toastError); return; }
saved();
}
catch (err) {
const refusal = refusalForThrown(err, process.env.NODE_ENV === 'production');
toastError(refusal
? t(`actionRefusal.${refusal}`)
: err instanceof Error && err.message ? err.message : t('globalError.somethingWentWrong'));
}
finally { submitting.current = false; }
});
}}
className="mt-4 rounded-2xl border border-brand/30 bg-brand/[0.05] p-4"
>
<label htmlFor="pw-text" className="text-xs font-bold uppercase tracking-wide text-muted">
Expand All @@ -375,10 +423,12 @@ function Composer({ onDone }: { onDone: () => void }) {
placeholder={t('paperwork.fromSchoolCoachClinicOptional')}
className="h-10 flex-1 rounded-xl border border-border bg-bg px-3 text-sm text-fg outline-none ring-brand/50 placeholder:text-muted focus:ring-2"
/>
{/* aria-disabled, not disabled: a natively disabled submit drops the
keyboard focus that pressed it to <body> while the action runs. */}
<button
type="submit"
disabled={pending}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand px-5 text-sm font-bold text-brand-fg transition hover:opacity-90 disabled:opacity-60"
aria-disabled={pending || undefined}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand px-5 text-sm font-bold text-brand-fg transition hover:opacity-90 aria-disabled:opacity-60"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{t('paperwork.triageIt')}
Expand Down
27 changes: 24 additions & 3 deletions components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,28 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<ToastContext.Provider value={api}>
{children}
{/* Above the mobile bottom tab bar (4rem, visible until lg) + home indicator;
only drops to bottom-6 once the bar is gone (M-033). */}
the offset only changes once the bar is gone (M-033). From lg it sits
bottom-right, lifted 10rem, and each toast is at most 14rem wide:
bottom-centre, a 384px toast covered a centred dialog's own footer
(its submit and Cancel) at 1024-1280px, the moment an error was
shown. A centred 512px dialog leaves 256px either side at 1024px, so
the narrower width is what clears it; longer messages wrap. The lift
clears the two corner controls under it, Quick capture (bottom
1.5rem) and the AI orb (bottom 6rem, its top 9.5rem up), which at
bottom-6 the stack covered. Raised, it may instead sit over the right
edge of the page's own content on some pages (it pauses on hover and
dismisses itself). Below lg those two controls sit in a column at the
right edge (3.5rem wide at right 1rem, bottom 5rem and 9rem), level
with the stack, so the stack stops 4.75rem from the right
(pr-[calc(4.75rem+var(--safe-right))], a 0.25rem gap) and sits left
of that column: a full-width notice there took the tap meant for
Quick capture, and a long one or three the orb's too. Narrower, a
long notice wraps to more lines and reaches further up the page and
into a bottom-sheet dialog's lower fields; at 5rem the longest
German notice took a seventh line at 360px and covered the contact
dialog's relationship chips, which 4.75rem keeps clear. Three
notices still reach page content and those chips there.
A11Y-001 #2, on #778. */}
{/* Pause on hover AND on focus. Focus is the half that matters for the
keyboard user this is meant to help: `onFocusCapture` fires as the
focus lands anywhere inside the stack, including on the Undo button
Expand All @@ -141,7 +162,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
onMouseLeave={resumeAll}
onFocusCapture={pauseAll}
onBlurCapture={resumeAll}
className="pointer-events-none fixed inset-x-0 bottom-[calc(5rem+var(--safe-bottom))] z-[100] flex flex-col items-center gap-2 px-4 lg:bottom-6"
className="pointer-events-none fixed inset-x-0 bottom-[calc(5rem+var(--safe-bottom))] z-[100] flex flex-col items-center gap-2 pl-4 pr-[calc(4.75rem+var(--safe-right))] lg:inset-x-auto lg:right-4 lg:bottom-40 lg:items-end lg:px-0"
>
{toasts.map((t) => {
const Icon = ICONS[t.tone];
Expand All @@ -151,7 +172,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
role={t.tone === 'error' ? 'alert' : 'status'}
aria-live={t.tone === 'error' ? 'assertive' : 'polite'}
className={cn(
'pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl popover-surface px-4 py-3 text-sm shadow-glass animate-fade-in',
'pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl popover-surface px-4 py-3 text-sm shadow-glass animate-fade-in lg:max-w-56',
t.tone === 'success' && 'border-success/30',
t.tone === 'error' && 'border-danger/30',
)}
Expand Down
Loading
Loading