From e0dbaa25ad4b292bcc28447fc0ed86bc35bd06b5 Mon Sep 17 00:00:00 2001 From: dame Date: Fri, 17 Jul 2026 07:49:15 -0400 Subject: [PATCH] Deferred review follow-ups: error states, record not-found, a11y modals, perf (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UX: surface UFOs API outages instead of rendering them as empty data The UFOs client swallowed request failures into empty results, so an API outage rendered as authoritative "No lexicons matched in this window." / "No lexicons found." — the error branches in TrendingLexicons and BrowseAllLexicons were unreachable because the client never signalled failure. fetchCollections now returns a `failed` flag distinguishing a real failure (null response) from a genuinely empty result. fetchRanking throws on it so the trending leaderboard shows its existing "Couldn't reach the UFOs API" state, and BrowseAllLexicons shows the same instead of a false empty state. Existing callers ignore the added field. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DF1WX7iG9AL7FVXVbmmPNo * UX: tell visitors when a shared record can't be previewed Universal-link record and post pages rendered the client picker unconditionally even when the record fetch returned null, so a visitor following a link to a deleted or unavailable record saw the full "choose a client" picker with no indication the preview was missing. Add a notice above the picker on both the generic record route and the post route. The fetch returns null for both a deleted record and a transient host failure, so the copy covers both honestly; the picker still renders since its client links work if the record exists elsewhere or the host recovers. Verified: notice shows for a nonexistent rkey, and valid records (recordData present) never trigger it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DF1WX7iG9AL7FVXVbmmPNo * a11y: keep collapsed compact-header panels out of the tab order + add Escape On every universal-link page the compact header's nav and search panels are hidden only with scaleY(0)/opacity/pointer-events, so keyboard and screen-reader users tabbed through invisible nav links, the theme toggle, sign-in controls, and the search input. Mark both panels `inert` when collapsed (React 19) to remove them from the tab order and accessibility tree, and add an Escape handler so the open nav menu can be dismissed by keyboard (previously only an outside mouse click closed it). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DF1WX7iG9AL7FVXVbmmPNo * a11y: shared accessible JsonModal for the margin "view raw data" dialogs All seven margin.* previews carried their own copy of a plain fixed
overlay with no dialog semantics, no Escape, and no focus management — a screen reader heard nothing on open and keyboard focus stayed on the covered page. Replace the seven copies with one built on the native element via showModal(), which provides role=dialog + aria-modal, a focus trap, Escape-to-close, focus moved into the dialog on open, and focus restored to the trigger on close. Verified end-to-end against a live at.margin.annotation record: the dialog opens labeled, focus lands inside it, and Escape closes it; the visual (centered card, blurred backdrop, JSON pane) is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DF1WX7iG9AL7FVXVbmmPNo * perf: cap concurrent handle lookups on the PDS explorer A PDS page renders up to ~50 repo rows, each firing a describeRepo handle lookup on mount — so opening one hit the PDS with ~50 simultaneous requests (rate-limit / hammer risk). Route the lookups through a small semaphore (6 concurrent); rows still resolve their handle as slots free up, at a fraction of the burst. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DF1WX7iG9AL7FVXVbmmPNo --------- Co-authored-by: Claude --- src/app/[handle]/[collection]/[rkey]/page.tsx | 26 +++ src/app/profile/[handle]/post/[rkey]/page.tsx | 28 ++- src/components/Header.tsx | 18 ++ src/components/explore/PdsExplorer.tsx | 33 ++- src/components/explore/TrendingLexicons.tsx | 9 +- .../explore/lexicons/BrowseAllLexicons.tsx | 15 +- src/components/margin/JsonModal.tsx | 204 ++++++++++++++++++ .../margin/MarginAnnotationPreview.tsx | 136 +----------- .../margin/MarginBookmarkPreview.tsx | 136 +----------- .../margin/MarginCollectionItemPreview.tsx | 136 +----------- .../margin/MarginCollectionPreview.tsx | 136 +----------- .../margin/MarginHighlightPreview.tsx | 136 +----------- src/components/margin/MarginLikePreview.tsx | 136 +----------- src/components/margin/MarginReplyPreview.tsx | 136 +----------- src/utils/ufos/client.ts | 7 +- 15 files changed, 396 insertions(+), 896 deletions(-) create mode 100644 src/components/margin/JsonModal.tsx diff --git a/src/app/[handle]/[collection]/[rkey]/page.tsx b/src/app/[handle]/[collection]/[rkey]/page.tsx index 0701ca0..dfd236f 100644 --- a/src/app/[handle]/[collection]/[rkey]/page.tsx +++ b/src/app/[handle]/[collection]/[rkey]/page.tsx @@ -460,6 +460,32 @@ async function RecordContent({ handle, collection, rkey }: { handle: string; col
)} + {/* The record fetch returns null for both a deleted/nonexistent record + and a transient host failure, so this notice covers both honestly + rather than silently showing the picker with no preview and no + explanation. The picker still renders below — its client links work + if the record exists elsewhere or the host recovers. */} + {!recordData && ( +
+ + We couldn’t load a preview for this record. + {' '} + It may have been deleted, or the account’s host server may be + temporarily unavailable. You can still try opening it in a client below. +
+ )} + - )} + {/* Covers both a deleted post and a transient host failure (the fetch + returns null for both), so the visitor isn't shown a bare picker + with no explanation. The picker still renders below. */} + {!post && ( +
+ + We couldn’t load a preview for this post. + {' '} + It may have been deleted, or the account’s host server may be + temporarily unavailable. You can still try opening it in a client below. +
+ )} + { + if (event.key === 'Escape') { + setIsExpanded(false); + setIsSearchExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); }; }, [compact, isExpanded, isSearchExpanded]); @@ -217,6 +228,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps) {/* Search panel — sits in the same expanding region as the menu panel but is its own card so the two slide independently. */} - {displayed === null ? ( + {error && (displayed === null || displayed.length === 0) ? ( +
+ Couldn’t reach the UFOs API: {error} +
+ ) : displayed === null ? ( ) : displayed.length === 0 ? (

diff --git a/src/components/margin/JsonModal.tsx b/src/components/margin/JsonModal.tsx new file mode 100644 index 0000000..cd21769 --- /dev/null +++ b/src/components/margin/JsonModal.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { X } from 'lucide-react'; + +/** + * Shared "view raw record data" modal for the margin.* previews. Built on the + * native

element via showModal(), which provides the accessibility the + * old hand-rolled fixed-div versions lacked: role=dialog + aria-modal, a focus + * trap, Escape-to-close, focus moved into the dialog on open, and focus + * restored to the trigger on close. Previously each of the seven previews + * carried its own copy of a plain
overlay with none of that. + */ +export default function JsonModal({ + open, + onClose, + title, + subtitle, + value, +}: { + open: boolean; + onClose: () => void; + title: string; + subtitle: string; + value: unknown; +}) { + const ref = useRef(null); + + // Drive the native modal state from the `open` prop. + useEffect(() => { + const dlg = ref.current; + if (!dlg) return; + if (open && !dlg.open) { + dlg.showModal(); + } else if (!open && dlg.open) { + dlg.close(); + } + }, [open]); + + // The dialog fires `close` on Escape (and on dlg.close()); mirror it back so + // the parent's `showJsonModal` state stays in sync. + useEffect(() => { + const dlg = ref.current; + if (!dlg) return; + const handleClose = () => onClose(); + dlg.addEventListener('close', handleClose); + return () => dlg.removeEventListener('close', handleClose); + }, [onClose]); + + return ( + { + if (e.target === ref.current) onClose(); + }} + > +
+
+
+
+ {title} +
+
+ {subtitle} +
+
+ +
+ +
+
+            {JSON.stringify(value, null, 2)}
+          
+
+
+ + +
+ ); +} diff --git a/src/components/margin/MarginAnnotationPreview.tsx b/src/components/margin/MarginAnnotationPreview.tsx index 745d4da..2e9dd66 100644 --- a/src/components/margin/MarginAnnotationPreview.tsx +++ b/src/components/margin/MarginAnnotationPreview.tsx @@ -8,7 +8,8 @@ import { useState } from 'react'; import { GenericRecord } from '@/utils/recordFetcher'; import { sanitizeUrl } from '@/utils/sanitize'; -import { X, ExternalLink, Tag, Calendar } from 'lucide-react'; +import { ExternalLink, Tag, Calendar } from 'lucide-react'; +import JsonModal from './JsonModal'; type MarginAnnotationPreviewProps = { record: GenericRecord; @@ -297,132 +298,13 @@ export default function MarginAnnotationPreview({
- {/* Full JSON Modal */} - {showJsonModal && ( -
setShowJsonModal(false)} - > -
e.stopPropagation()} - > -
-
-
- Raw Annotation Data -
-
- at.margin.annotation -
-
- -
- -
-
-                {JSON.stringify(value, null, 2)}
-              
-
-
-
- )} + setShowJsonModal(false)} + title="Raw Annotation Data" + subtitle="at.margin.annotation" + value={value} + />