From 6d27f916197b53e5968efb25704f8c2117f9ca5d Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Tue, 6 Oct 2026 09:28:13 -0400 Subject: [PATCH 1/5] Finish brand-native states: announce failures, consolidate StatePanel (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk of #209 already landed via #216 (StatePanel + BubbleLoader primitives, layout-stable loading, true/filtered empty states, actionable errors, branded 404, reduced-motion bubbles). This closes the remaining gaps found auditing every public non-happy-path surface: - Booking widget failure now uses StatePanel with role="alert" — the "Loading availability" status region unmounts on failure, so the error panel previously appeared with no live-region announcement at all - Partners filtered-empty panel converted to the shared StatePanel (was a byte-identical bespoke duplicate) - StatePanel gains an optional titleClassName for prominent failures - Global error boundary gains an escape route: retry alone left a persistent failure with no way back except the URL bar - /dive-log gets a noscript fallback — without JS the client component sits on its loading state forever with no explanation No backend or business-logic changes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/(en)/(content)/dive-log/page.tsx | 27 +++++++++++++++++- app/global-error.tsx | 10 +++++++ components/booking-widget.tsx | 28 ++++++++++++------- .../partners/accommodations-section.tsx | 14 ++++++---- components/state-panel.tsx | 5 +++- .../accommodations-section.test.tsx | 16 +++++++++++ tests/integration/booking-widget.test.tsx | 12 ++++++++ 7 files changed, 95 insertions(+), 17 deletions(-) diff --git a/app/(en)/(content)/dive-log/page.tsx b/app/(en)/(content)/dive-log/page.tsx index 8dd6b1b..c1b3941 100644 --- a/app/(en)/(content)/dive-log/page.tsx +++ b/app/(en)/(content)/dive-log/page.tsx @@ -9,5 +9,30 @@ export const metadata = createMetadata({ }); export default function DiveLogPage() { - return ; + return ( + <> + + {/* Without JS the client component sits on its loading state forever + — say so and offer a real next step instead (issue #209). */} + + + ); } diff --git a/app/global-error.tsx b/app/global-error.tsx index 0c757e1..7530d2a 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -39,6 +39,16 @@ export default function GlobalError({ > Try again + {/* Escape route for persistent failures — a retry that keeps + failing shouldn't strand the visitor on a dead-end page. */} +

+ + If it keeps happening, head back to the homepage + +

diff --git a/components/booking-widget.tsx b/components/booking-widget.tsx index 73c0f53..c932de7 100644 --- a/components/booking-widget.tsx +++ b/components/booking-widget.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { BubbleLoader } from "@/components/bubble-loader"; +import { StatePanel } from "@/components/state-panel"; import { buttonVariants } from "@/components/ui/button"; import { trackLinkClick } from "@/lib/analytics"; import { cn } from "@/lib/utils"; @@ -157,15 +158,22 @@ export function BookingWidget({ item }: BookingWidgetProps) { return (
{status === "error" && ( -
-

- Booking isn't loading -

-

- Open our secure booking page directly - {itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another - way — we'll get you booked. -

+ // role="alert": the "Loading availability" status region unmounts + // when the widget fails, so nothing would announce the failure — + // the panel must speak for itself (issue #209). + + Open our secure booking page directly + {itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another + way — we'll get you booked. + + } + > -
+ )} {unknown && ( diff --git a/components/partners/accommodations-section.tsx b/components/partners/accommodations-section.tsx index d3f2813..79db250 100644 --- a/components/partners/accommodations-section.tsx +++ b/components/partners/accommodations-section.tsx @@ -5,6 +5,7 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation"; import Link from "next/link"; import { ExternalLink, MapPin, Search, X } from "lucide-react"; import { Pill } from "@/components/ui/pill"; +import { StatePanel } from "@/components/state-panel"; import { buttonVariants } from "@/components/ui/button"; import { trackLinkClick } from "@/lib/analytics"; import { cn } from "@/lib/utils"; @@ -237,18 +238,21 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP )}
- {/* No results */} + {/* No results — shared StatePanel; no live role, the result count + heading already announces the change (issue #209). */} {filtered.length === 0 && ( -
-

No accommodations match your filters.

-

Try clearing a filter or searching for a different term.

+ -
+ )} {/* Hotels */} diff --git a/components/state-panel.tsx b/components/state-panel.tsx index 4fcaf0e..6bb5382 100644 --- a/components/state-panel.tsx +++ b/components/state-panel.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"; */ export function StatePanel({ title, + titleClassName, description, leading, role, @@ -23,6 +24,8 @@ export function StatePanel({ className, }: { title: string; + /** Override for prominent failures — default is the quiet text-sm title. */ + titleClassName?: string; description?: ReactNode; /** Visual shown above the title — BubbleLoader, icon, etc. */ leading?: ReactNode; @@ -40,7 +43,7 @@ export function StatePanel({ )} > {leading} -

{title}

+

{title}

{description && (

{description}

)} diff --git a/tests/integration/accommodations-section.test.tsx b/tests/integration/accommodations-section.test.tsx index 17d1b88..6c2e346 100644 --- a/tests/integration/accommodations-section.test.tsx +++ b/tests/integration/accommodations-section.test.tsx @@ -67,3 +67,19 @@ it("announces filter pills as toggles, not as current navigation items", async ( expect(screen.getByRole("heading", { name: "1 accommodation" })).toBeVisible(); expect(screen.queryByRole("heading", { name: "Bay Cottage" })).toBeNull(); }); + +// Issue #209: a filtered-empty result must explain what happened and offer +// the way back — never a silent zero-result list. +it("explains an empty filter result and offers a way back", async () => { + render(); + await userEvent.type( + screen.getByRole("searchbox", { name: "Search accommodations" }), + "zzz-no-match" + ); + const panel = screen.getByText("No accommodations match your filters."); + expect(panel).toBeVisible(); + // The recovery action actually restores the list. + await userEvent.click(screen.getByRole("button", { name: "Clear all filters" })); + expect(screen.getByRole("heading", { name: "2 accommodations" })).toBeVisible(); + expect(screen.queryByText("No accommodations match your filters.")).toBeNull(); +}); diff --git a/tests/integration/booking-widget.test.tsx b/tests/integration/booking-widget.test.tsx index d0ba751..05d6d60 100644 --- a/tests/integration/booking-widget.test.tsx +++ b/tests/integration/booking-widget.test.tsx @@ -110,6 +110,18 @@ it("keeps the error fallback free of the loading treatment", () => { expect(document.querySelector(".animate-bubble-rise")).toBeNull(); }); +it("announces the failure — the loading status unmounts, so the panel speaks for itself (#209)", () => { + render(); + fireEvent.error(script()); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Booking isn't loading"); + expect(alert).toHaveTextContent(/secure booking page/i); + // The recovery paths stay inside the announced region. + expect(alert).toContainElement( + screen.getByRole("link", { name: /continue to secure booking system/i }) + ); +}); + it("reuses the script on navigation and stops polling after unmount", () => { const existing = document.createElement("script"); existing.id = "checkfront-interface-script"; From 497c1ad239814948f10555caee9f04baca16e107 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Tue, 6 Oct 2026 09:40:46 -0400 Subject: [PATCH 2/5] Escape route keeps a plain anchor in the global error boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next/link's depends on router context — the thing this boundary exists to survive losing. A plain
forces a full navigation, which re-bootstraps the app and is the recovery the escape route is for. Documented inline with a targeted eslint-disable. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/global-error.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/global-error.tsx b/app/global-error.tsx index 7530d2a..020237f 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -42,6 +42,11 @@ export default function GlobalError({ {/* Escape route for persistent failures — a retry that keeps failing shouldn't strand the visitor on a dead-end page. */}

+ {/* Plain on purpose: this boundary renders when the root + layout — and possibly the router context needs — + has already failed. A full navigation re-bootstraps the + app, which is the recovery the escape route exists for. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} Date: Tue, 6 Oct 2026 09:50:32 -0400 Subject: [PATCH 3/5] Deflake mobile-nav keyboard e2e (pre-existing master flake) Identical failure observed on master's #228 run and on this branch's CI: the aria-expanded attribute poll can resolve before the browser applies the open panel's restored focusability, so the Tab probe lands on the page's hero CTA instead of entering the menu. Wait for the inert removal and link visibility first. Same fix as on #229. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/e2e/accessibility.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 3c6ded9..3e7e5e3 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -170,6 +170,13 @@ test.describe("keyboard operation", () => { await mobileNav.getByRole("link", { name: "Diving" }).focus(); await expect(mobileNav.getByRole("link", { name: "Diving" })).toBeFocused(); } else { + // `aria-expanded` and the `inert` removal land in the same React + // commit, but on slow runners the attribute poll can resolve before + // the browser applies the panel's restored focusability — observed + // flake: Tab landing on the page's hero CTA instead of entering the + // menu. Wait for the panel to be interactive before probing Tab. + await expect(mobileNav).not.toHaveAttribute("inert", ""); + await expect(mobileNav.getByRole("link", { name: "Diving" })).toBeVisible(); await page.keyboard.press("Tab"); const focused = page.locator(":focus"); await expect(focused).toHaveText("Diving"); From 60b9951b4c16ad27a5308bda9c9587133846560f Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Wed, 7 Oct 2026 11:45:26 -0400 Subject: [PATCH 4/5] Soften the dive-log noscript fallback copy Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/(en)/(content)/dive-log/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(en)/(content)/dive-log/page.tsx b/app/(en)/(content)/dive-log/page.tsx index c1b3941..b5345af 100644 --- a/app/(en)/(content)/dive-log/page.tsx +++ b/app/(en)/(content)/dive-log/page.tsx @@ -16,7 +16,7 @@ export default function DiveLogPage() { — say so and offer a real next step instead (issue #209). */}

- The dive log needs JavaScript to load recent dives. You can still{" "} + Recent dive activity needs JavaScript to load. You can still{" "} Date: Wed, 7 Oct 2026 12:17:11 -0400 Subject: [PATCH 5/5] Announce accommodations zero-result count as a polite live region The filtered-empty StatePanel mounted silently: focus stays on the filter control and the result-count heading had no live role, so a screen reader user got no signal that the list emptied. The always- mounted count is now aria-live=polite + aria-atomic, so "0 accommodations" is announced as the filter narrows (CodeRabbit finding on #231). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- components/partners/accommodations-section.tsx | 12 ++++++++---- tests/integration/accommodations-section.test.tsx | 5 +++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/components/partners/accommodations-section.tsx b/components/partners/accommodations-section.tsx index 661716c..83d3b79 100644 --- a/components/partners/accommodations-section.tsx +++ b/components/partners/accommodations-section.tsx @@ -223,9 +223,12 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP )} - {/* Results header */} + {/* Results header — the count is a polite live region so a filter + change to zero results is announced while focus stays on the + filter. aria-atomic reads the whole new count, not just the + changed digit. */}

-

+

{filtered.length} accommodation{filtered.length !== 1 ? "s" : ""}

{hasActiveFilters && ( @@ -238,8 +241,9 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP )}
- {/* No results — shared StatePanel; no live role, the result count - heading already announces the change (issue #209). */} + {/* No results — shared StatePanel; no live role of its own, the + live result-count heading above already announces the change + (issue #209). */} {filtered.length === 0 && ( { screen.getByRole("searchbox", { name: "Search accommodations" }), "zzz-no-match" ); + // The always-mounted result count is a polite live region, so the zero + // state is announced while focus stays on the filter control. + const count = screen.getByRole("heading", { name: "0 accommodations" }); + expect(count).toHaveAttribute("aria-live", "polite"); + expect(count).toHaveAttribute("aria-atomic", "true"); const panel = screen.getByText("No accommodations match your filters."); expect(panel).toBeVisible(); // The recovery action actually restores the list.