From 3988990baf538648f8e6be0fcb60b252ea9439ba Mon Sep 17 00:00:00 2001 From: edwina Date: Sun, 18 Oct 2026 18:52:34 +0100 Subject: [PATCH 1/4] fix: only the top modal handles Escape and focus trapping (#524) Modals register on a shared stack, so Escape closes the topmost one and Tab stays inside it. Focus returns to the opener when the modal unmounts. Closes #524 Co-Authored-By: Claude Haiku 5.5 --- src/components/dashboard/Modal.tsx | 42 +++++++++++++++++++++++++++--- src/lib/useFocusTrap.ts | 22 +++++++++------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/components/dashboard/Modal.tsx b/src/components/dashboard/Modal.tsx index 9f40e30..f3f42da 100644 --- a/src/components/dashboard/Modal.tsx +++ b/src/components/dashboard/Modal.tsx @@ -1,10 +1,33 @@ "use client"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { QRCodeSVG } from "qrcode.react"; import { CopyButton } from "@/components/CopyButton"; import { useFocusTrap } from "@/lib/useFocusTrap"; +// Open modals, outermost first. Only the top one handles Escape and traps Tab. +const modalStack: string[] = []; +const stackListeners = new Set<() => void>(); + +function notifyStack() { + stackListeners.forEach((listener) => listener()); +} + +function subscribeStack(listener: () => void) { + stackListeners.add(listener); + return () => { + stackListeners.delete(listener); + }; +} + +function useIsTopModal(id: string) { + return useSyncExternalStore( + subscribeStack, + () => modalStack[modalStack.length - 1] === id, + () => false, + ); +} + export function Modal({ title, onClose, @@ -16,16 +39,27 @@ export function Modal({ }) { const titleId = useId(); const panelRef = useRef(null); - useFocusTrap(panelRef); + const isTop = useIsTopModal(titleId); + useFocusTrap(panelRef, isTop); + + useEffect(() => { + modalStack.push(titleId); + notifyStack(); + return () => { + modalStack.splice(modalStack.indexOf(titleId), 1); + notifyStack(); + }; + }, [titleId]); - // Escape closes, matching native dialog behaviour for keyboard users. + // Escape closes, matching native dialog behaviour for keyboard users. Only the top modal reacts. useEffect(() => { + if (!isTop) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose]); + }, [isTop, onClose]); return (
) { +/** + * Traps Tab focus inside `ref` while `enabled`, focusing it when enabled. Prior focus is + * restored on unmount. Nested modals pass `enabled` only for the top one. + */ +export function useFocusTrap(ref: RefObject, enabled = true) { const previous = useRef(null); useEffect(() => { - const root = ref.current; - if (!root) return; previous.current = document.activeElement as HTMLElement | null; + return () => previous.current?.focus?.(); + }, []); + + useEffect(() => { + const root = ref.current; + if (!root || !enabled) return; const items = () => Array.from(root.querySelectorAll(FOCUSABLE)); (items()[0] ?? root).focus(); @@ -34,9 +41,6 @@ export function useFocusTrap(ref: RefObject) { } } document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("keydown", onKey); - previous.current?.focus?.(); - }; - }, [ref]); + return () => document.removeEventListener("keydown", onKey); + }, [ref, enabled]); } From b4ab2fd4adf1b8dc3f39d04c3d7c8a701796e23b Mon Sep 17 00:00:00 2001 From: edwina Date: Sun, 18 Oct 2026 18:52:34 +0100 Subject: [PATCH 2/4] fix: link the checkout privacy notice to /privacy (#525) Opens in a new tab so the payer keeps the checkout. Closes #525 Co-Authored-By: Claude Haiku 5.5 --- src/components/checkout/PayerPrivacyNotice.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/checkout/PayerPrivacyNotice.tsx b/src/components/checkout/PayerPrivacyNotice.tsx index f1ed486..b0b43de 100644 --- a/src/components/checkout/PayerPrivacyNotice.tsx +++ b/src/components/checkout/PayerPrivacyNotice.tsx @@ -2,7 +2,16 @@ export function PayerPrivacyNotice({ merchantName }: { merchantName: string }) { return (

- Shared with {merchantName} and Octo to process this payment. See our Privacy Policy. + Shared with {merchantName} and Octo to process this payment. See our{" "} + + Privacy Policy + + .

); } From ef43bc5c438e347a63e7b2dc8ea305a8a6e18eb9 Mon Sep 17 00:00:00 2001 From: edwina Date: Sun, 18 Oct 2026 18:52:34 +0100 Subject: [PATCH 3/4] fix: show the consent line only on signup and use one Terms link (#526) Closes #526 Co-Authored-By: Claude Haiku 5.5 --- src/app/signup/page.tsx | 2 +- src/components/auth/AuthShell.tsx | 37 ++++++++++++++++++------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 87a86c9..165e2cb 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -9,7 +9,7 @@ export const metadata = { title: "Sign up — Octo" }; export default function SignupPage() { return ( - + ); diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx index 56d3152..afde3b1 100644 --- a/src/components/auth/AuthShell.tsx +++ b/src/components/auth/AuthShell.tsx @@ -5,7 +5,14 @@ import { AuthWalletScene } from "@/components/auth/AuthWalletScene"; /** Split-screen auth layout: glass form card on the left, brand copy on the * right, both floating over a full-screen underwater "secure deposits" scene. */ -export function AuthShell({ children }: { children: React.ReactNode }) { +export function AuthShell({ + children, + consent = false, +}: { + children: React.ReactNode; + /** Show the signup consent line. Only the signup page sets it. */ + consent?: boolean; +}) { return (
{/* Fixed backdrop: burgundy glow + faint isometric grid. The underwater @@ -46,21 +53,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
{children} -

- By signing up, you agree to our{" "} - - Terms - {" "} - and{" "} - - Conditions of use - {" "} - and{" "} - - Privacy policy - - . -

+ {consent && ( +

+ By signing up, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy policy + + . +

+ )}
From 742ee00b2ccd01fd9e3c4018f3f090d82f61fcba Mon Sep 17 00:00:00 2001 From: edwina Date: Sun, 18 Oct 2026 18:52:35 +0100 Subject: [PATCH 4/4] fix: forward aria-describedby and aria-invalid from OtpInput to its digits (#527) Closes #527 Co-Authored-By: Claude Haiku 5.5 --- src/components/auth/OtpInput.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/auth/OtpInput.tsx b/src/components/auth/OtpInput.tsx index 99f9cf9..42026d7 100644 --- a/src/components/auth/OtpInput.tsx +++ b/src/components/auth/OtpInput.tsx @@ -7,10 +7,14 @@ export function OtpInput({ value, onChange, disabled, + "aria-describedby": describedBy, + "aria-invalid": invalid, }: { value: string; onChange: (code: string) => void; disabled?: boolean; + "aria-describedby"?: string; + "aria-invalid"?: "true" | "false"; }) { const inputs = useRef>([]); const digits = Array.from({ length: 6 }, (_, i) => value[i] ?? ""); @@ -50,6 +54,8 @@ export function OtpInput({ inputMode="numeric" autoComplete={i === 0 ? "one-time-code" : "off"} aria-label={`Digit ${i + 1} of 6`} + aria-describedby={describedBy} + aria-invalid={invalid} maxLength={1} className="h-12 w-10 rounded-lg border border-border bg-surface-sunken text-center text-lg font-semibold text-foreground focus:border-burgundy-bright focus:outline-none disabled:opacity-60" />