From 267588f1fe0f58d92f7b52819c64242209bc1722 Mon Sep 17 00:00:00 2001 From: holke <103380539+holkexyz@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:41:59 +0000 Subject: [PATCH 01/42] docs(review): full-codebase review findings + refactor plan 10-dimension review with adversarial verification: 62 findings, 36 confirmed. Records the executable prompt, per-finding evidence/verdicts, and the disjoint-ownership implementation plan (34 fixes across 11 tracks, 2 deferred). Co-Authored-By: Claude Opus 4.8 --- docs/full-review-2026-07/findings.md | 370 +++++++++++++++++++++++++++ docs/full-review-2026-07/plan.md | 208 +++++++++++++++ docs/full-review-2026-07/prompt.md | 158 ++++++++++++ 3 files changed, 736 insertions(+) create mode 100644 docs/full-review-2026-07/findings.md create mode 100644 docs/full-review-2026-07/plan.md create mode 100644 docs/full-review-2026-07/prompt.md diff --git a/docs/full-review-2026-07/findings.md b/docs/full-review-2026-07/findings.md new file mode 100644 index 00000000..9b3b9b1c --- /dev/null +++ b/docs/full-review-2026-07/findings.md @@ -0,0 +1,370 @@ +# Full review findings — certified-app (2026-07-02) + +Generated by the 10-dimension review workflow defined in [`prompt.md`](./prompt.md). Each finding was adversarially verified: critical/high by two skeptics (reachability + impact lenses), medium/low by one. A finding is **confirmed** only if a skeptic confirmed it and none refuted it. + +**Totals:** 62 findings — **36 confirmed**, 0 uncertain, 26 refuted. + +Provenance: raw verified data in the workflow journal (`subagents/workflows/wf_8beca369-113/journal.jsonl`). + +> **Caveat on the testing dimension.** The verification lens ("is there a defect at this file:line?") does not fit *missing-test* findings — an absent test is not a code defect to refute — so all Dimension-9 findings landed in the refuted bucket. They are nonetheless valid coverage gaps. The high-value ones (untested cross-account password-reset, clone-blob SSRF guards, upload-blob limits, members anti-escalation, feedback abuse config) are **actioned** via the test-only track in [`plan.md`](./plan.md), not dropped. + +--- + +## Confirmed findings (36) + +### 1. Global X-Frame-Options: DENY + CSP frame-ancestors 'none' break the shipped /embed/board third-party embed feature + +- **id / dimension:** `next-embed-frame-headers-block-embeds` · nextjs +- **severity / effort / regression-risk:** High / S / low +- **location:** `next.config.ts:27` +- **evidence:** headers() applies to `source: "/(.*)"` and sets `{ key: "X-Frame-Options", value: "DENY" }` (line 27) plus CSP `... frame-ancestors 'none'; ...` (lines 61-62), for BOTH prod and dev. There is no embed-specific override (grep of next.config.ts finds no /embed header rule; there is no middleware.ts). Yet `src/app/embed/board/[...slug]/page.tsx` is documented as 'Public embed route ... Renders bare ... so it drops cleanly into a third-party iframe', and `src/components/contributor-board/share-embed-dialog.tsx:20` hands users `` - const [copied, setCopied] = useState(null) - const copy = async (text: string, key: string) => { - try { - await navigator.clipboard.writeText(text) - setCopied(key) - window.setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500) - } catch { - /* clipboard unavailable — no-op */ - } - } + // One shared-hook instance per copy target so each button shows its + // own check mark; the hook auto-resets after 1500ms. + const { copied: linkCopied, copy: copyLink } = useCopyToClipboard() + const { copied: embedCopied, copy: copyEmbed } = useCopyToClipboard() return ( @@ -48,9 +42,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps) size="icon" variant="secondary" aria-label="Copy link" - onClick={() => copy(shareUrl, "link")} + onClick={() => void copyLink(shareUrl)} > - {copied === "link" ? : } + {linkCopied ? : } @@ -70,9 +64,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps) size="icon" variant="secondary" aria-label="Copy embed code" - onClick={() => copy(embedCode, "embed")} + onClick={() => void copyEmbed(embedCode)} > - {copied === "embed" ? : } + {embedCopied ? : } diff --git a/src/components/create/location-picker-dialog.tsx b/src/components/create/location-picker-dialog.tsx index 4129303c..6adaf619 100644 --- a/src/components/create/location-picker-dialog.tsx +++ b/src/components/create/location-picker-dialog.tsx @@ -99,10 +99,18 @@ export default function LocationPickerDialog({ const [myLocationsError, setMyLocationsError] = useState(null) const [selectedExistingUri, setSelectedExistingUri] = useState("") - useEffect(() => { - const controller = new AbortController() + // Adjust state during render when the source repo changes (first mount + // already starts loading via the initializers), so the effect holds + // only the listRecords lifecycle. + const [prevOwnDid, setPrevOwnDid] = useState(ownDid) + if (prevOwnDid !== ownDid) { + setPrevOwnDid(ownDid) setMyLocationsLoading(true) setMyLocationsError(null) + } + + useEffect(() => { + const controller = new AbortController() const params = new URLSearchParams({ repo: ownDid, collection: "app.certified.location", @@ -177,6 +185,7 @@ export default function LocationPickerDialog({ if (mode !== "new" || fieldMode !== "search") return const trimmed = name.trim() if (trimmed.length < 2) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- debounced geocode keyed on the typed name: this clears now-stale suggestions when input drops below 2 chars and bails out when already empty; onChange/pick/map handlers don't cover every write path setSuggestions([]) return } diff --git a/src/components/dashboard/custom-domain-modal.tsx b/src/components/dashboard/custom-domain-modal.tsx index 970ffe76..d8ab5649 100644 --- a/src/components/dashboard/custom-domain-modal.tsx +++ b/src/components/dashboard/custom-domain-modal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Globe, Copy, Check, AlertCircle, CheckCircle2 } from "lucide-react"; import { authFetch } from "@/lib/auth/fetch"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { clearSessionCache } from "@/hooks/use-session"; import Button from "@/components/ui/button"; import AppDialog, { AppDialogHeader } from "@/components/ui/app-dialog"; @@ -30,7 +31,10 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain const [step, setStep] = useState("enter-domain"); const [domain, setDomain] = useState(""); - const [copied, setCopied] = useState<"host" | "value" | null>(null); + // One shared-hook instance per copy target so each DNS field shows its + // own check mark; the hook auto-resets after 2000ms. + const { copied: hostCopied, copy: copyHost } = useCopyToClipboard(2000); + const { copied: valueCopied, copy: copyValue } = useCopyToClipboard(2000); const [isVerifying, setIsVerifying] = useState(false); const [verifyError, setVerifyError] = useState(null); const [isSuccess, setIsSuccess] = useState(false); @@ -42,7 +46,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain if (isOpen) { setStep("enter-domain"); setDomain(""); - setCopied(null); setIsVerifying(false); setVerifyError(null); setIsSuccess(false); @@ -66,16 +69,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain setStep("dns-setup"); }; - const handleCopy = async (text: string, which: "host" | "value") => { - try { - await navigator.clipboard.writeText(text); - setCopied(which); - setTimeout(() => setCopied(null), 2000); - } catch { - // Fallback: select text - } - }; - const handleVerify = async () => { setIsVerifying(true); setVerifyError(null); @@ -220,11 +213,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain @@ -236,11 +229,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain diff --git a/src/components/feed/activity-edit-route.tsx b/src/components/feed/activity-edit-route.tsx index af39bdab..745b29a4 100644 --- a/src/components/feed/activity-edit-route.tsx +++ b/src/components/feed/activity-edit-route.tsx @@ -247,9 +247,9 @@ export default function ActivityEditRoute({ // Rights options — same listRecords call /create uses. // ------------------------------------------------------------------- useEffect(() => { + // No sync resets here: this []-dep effect runs once right after + // mount and the useState initializers are already true/null. const controller = new AbortController() - setRightsLoading(true) - setRightsLoadError(null) const qs = new URLSearchParams({ repo: RIGHTS_PUBLISHER_DID, collection: RIGHTS_COLLECTION, @@ -303,6 +303,7 @@ export default function ActivityEditRoute({ if (seededRef.current) return if (!activity) return const v = activity.value + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (seededRef) seeding of editable form state + swap-record cid baseline after the async record load; re-running would clobber user edits. Long-term fix is a key-remount form child, tracked separately setTitle(v.title ?? "") setShortDescription(v.shortDescription ?? "") setStartDate( @@ -344,13 +345,23 @@ export default function ActivityEditRoute({ // location record itself (one getRecord per strongRef) — we use the // same shape `LocationPickerDialog` emits so the row UI is uniform. // ------------------------------------------------------------------- + // The refs-empty reset is adjusted during render when the record + // identity changes (keyed on uri|cid, not object identity, which + // isn't render-stable); initial state is already []. Only the async + // name hydration lives in the effect. + const activityKey = activity ? `${activity.uri}|${activity.cid}` : null + const [prevActivityKey, setPrevActivityKey] = useState(activityKey) + if (prevActivityKey !== activityKey) { + setPrevActivityKey(activityKey) + if (activity && (activity.value.locations ?? []).length === 0) { + setLocations([]) + } + } + useEffect(() => { if (!activity) return const refs = activity.value.locations ?? [] - if (refs.length === 0) { - setLocations([]) - return - } + if (refs.length === 0) return let aborted = false Promise.all( refs.map(async (ref): Promise => { @@ -401,6 +412,7 @@ export default function ActivityEditRoute({ // Clear save error whenever any field changes. useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- deliberate cross-field watcher clearing the save error on any edit; setError(null) bails out (no render) whenever error is already null, so cost is one render only while an error is showing setError(null) }, [ title, diff --git a/src/components/layout/desktop-top-bar.tsx b/src/components/layout/desktop-top-bar.tsx index 0589c722..d127b7a5 100644 --- a/src/components/layout/desktop-top-bar.tsx +++ b/src/components/layout/desktop-top-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parseActor, profileUrl } from "@/lib/urls" import { CERT_DETAIL_TABS, @@ -267,35 +267,35 @@ export default function DesktopTopBar() { // Close the switcher on navigation. Positioning, click-outside and // Esc-to-close are owned by the primitive. useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- close switcher on route change (external router input); covers back/forward nav that no click handler sees; setState bails out when already closed setSwitcherOpen(false); }, [pathname]); // ----- Create-menu effects (mirror the switcher) ----- + // Anchor the menu's LEFT edge to the trigger's left edge so it opens + // to the right of the "+" button (no chance of running off the + // viewport's left side when the button sits near the chrome's right + // edge — same affordance as GitHub's "+" menu). The initial compute + // happens in the trigger's onClick; a stale anchor while closed is + // unobservable because the portal gates on createOpen && createAnchor. + const computeCreateAnchor = useCallback(() => { + const rect = createRef.current?.getBoundingClientRect(); + if (!rect) return; + setCreateAnchor({ + left: rect.left, + top: rect.bottom + 8, + }); + }, []); + useEffect(() => { - if (!createOpen || !createRef.current) { - setCreateAnchor(null); - return; - } - const compute = () => { - const rect = createRef.current?.getBoundingClientRect(); - if (!rect) return; - // Anchor the menu's LEFT edge to the trigger's left edge so it - // opens to the right of the "+" button (no chance of running off - // the viewport's left side when the button sits near the chrome's - // right edge — same affordance as GitHub's "+" menu). - setCreateAnchor({ - left: rect.left, - top: rect.bottom + 8, - }); - }; - compute(); - globalThis.addEventListener("resize", compute); - globalThis.addEventListener("scroll", compute, true); + if (!createOpen) return; + globalThis.addEventListener("resize", computeCreateAnchor); + globalThis.addEventListener("scroll", computeCreateAnchor, true); return () => { - globalThis.removeEventListener("resize", compute); - globalThis.removeEventListener("scroll", compute, true); + globalThis.removeEventListener("resize", computeCreateAnchor); + globalThis.removeEventListener("scroll", computeCreateAnchor, true); }; - }, [createOpen]); + }, [createOpen, computeCreateAnchor]); useEffect(() => { if (!createOpen) return; @@ -323,6 +323,7 @@ export default function DesktopTopBar() { }, [createOpen]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- close create-menu on route change; same rationale as the switcher close above setCreateOpen(false); }, [pathname]); @@ -382,6 +383,7 @@ export default function DesktopTopBar() { {breadcrumb || desktopShownTitle ? ( ) : ( + // eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG wordmark; next/image performs no optimization on SVG sources and the element is CSS-sized (no CLS) Certified setCreateOpen((v) => !v)} + onClick={() => { + if (!createOpen) computeCreateAnchor(); + setCreateOpen((v) => !v); + }} aria-haspopup="menu" aria-expanded={createOpen} aria-label="Create new" @@ -563,6 +568,7 @@ export default function DesktopTopBar() { className="desktop-top-bar__signin-btn" aria-label="Sign in" > + {/* eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG sign-in glyph; no optimization benefit from next/image */} + {/* eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG wordmark; no optimization benefit from next/image */} Certified { // Close dropdowns on navigation useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- close dropdown/switcher on route change (external router input); both setStates bail out when already false setDropdownOpen(false); setSwitcherOpen(false); }, [pathname]); @@ -85,6 +86,7 @@ const Navbar: React.FC = () => { // (the hamburger button and mobile sidebar unmount at ≥800px; leftover // `dropdownOpen` would re-open the drawer on resize back down). useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear sheet/sidebar state when crossing the 800px boundary (external matchMedia input); bails out when already false setDropdownOpen(false); setSwitcherOpen(false); }, [isDesktop]); diff --git a/src/components/lists/add-to-list-menu.tsx b/src/components/lists/add-to-list-menu.tsx index 91fad3d7..f0e78ca0 100644 --- a/src/components/lists/add-to-list-menu.tsx +++ b/src/components/lists/add-to-list-menu.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/popover" import { useAuth } from "@/lib/auth/auth-context" import { recordUrlFromAtUri } from "@/lib/urls" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { useTypedLists } from "@/hooks/use-typed-lists" import { itemUriMatchesType, @@ -77,7 +78,10 @@ export default function AddToListMenu({ const { did: viewerDid, openSignIn } = useAuth() const [open, setOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false) - const [copied, setCopied] = useState<"share" | "uri" | null>(null) + // One shared-hook instance per copy target so each menu item shows its + // own "Copied" label; the hook auto-resets the flags. + const { copied: shareCopied, copy: copyShare } = useCopyToClipboard() + const { copied: uriCopied, copy: copyUri } = useCopyToClipboard() // DID-based web path for sharing — recordUrlFromAtUri maps the // collection to its friendly route segment and defaults to the URI's @@ -95,25 +99,16 @@ export default function AddToListMenu({ return shareTab ? `${base}?tab=${encodeURIComponent(shareTab)}` : base }, [targetUri, shareTab]) - // Reset the brief "Copied" affordance whenever the popover opens - // again, so the previous run's feedback doesn't leak across opens. + // Keep the popover open briefly after a successful copy so the user + // sees the confirmation, then auto-close. Driven off the hook's copied + // flags — the hook swallows clipboard failures (flag stays false), so + // a failed copy leaves the popover open. + const anyCopied = shareCopied || uriCopied useEffect(() => { - if (open) setCopied(null) - }, [open]) - - const copyText = useCallback(async (text: string, which: "share" | "uri") => { - try { - await navigator.clipboard.writeText(text) - setCopied(which) - // Keep the popover open briefly so the user sees the - // confirmation, then auto-close. - window.setTimeout(() => { - setOpen(false) - }, 900) - } catch (err) { - console.error("Failed to copy:", err) - } - }, []) + if (!anyCopied) return + const t = window.setTimeout(() => setOpen(false), 900) + return () => window.clearTimeout(t) + }, [anyCopied]) // Shown to logged-out viewers too: "Copy AT URI" works without auth, // and "Add to list" funnels them to sign-in. Only the URI→type guard @@ -170,11 +165,11 @@ export default function AddToListMenu({ {sharePath ? ( - copyText(`${window.location.origin}${sharePath}`, "share") + void copyShare(`${window.location.origin}${sharePath}`) } > - {copied === "share" ? "Link copied" : "Share"} + {shareCopied ? "Link copied" : "Share"} ) : null} Add to list - copyText(targetUri, "uri")}> + void copyUri(targetUri)}> - {copied === "uri" ? "Copied" : "Copy AT URI"} + {uriCopied ? "Copied" : "Copy AT URI"} diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx index d50d6715..7bb1423d 100644 --- a/src/components/profile/profile-header.tsx +++ b/src/components/profile/profile-header.tsx @@ -85,11 +85,14 @@ export default function ProfileHeader({ // Track banner load failures so we fall back to the plain gradient // instead of showing the browser's broken-image icon. Reset the flag - // when the URL changes (e.g. when the user switches profiles). + // during render when the URL changes (e.g. when the user switches + // profiles) — React's adjust-state-during-render pattern. const [bannerFailed, setBannerFailed] = useState(false) - useEffect(() => { + const [prevBannerUrl, setPrevBannerUrl] = useState(bannerUrl) + if (prevBannerUrl !== bannerUrl) { + setPrevBannerUrl(bannerUrl) setBannerFailed(false) - }, [bannerUrl]) + } const hasAdminActions = !!editHref || !!settingsHref diff --git a/src/components/profile/profile-overview.tsx b/src/components/profile/profile-overview.tsx index 277a9be8..9a78b5a9 100644 --- a/src/components/profile/profile-overview.tsx +++ b/src/components/profile/profile-overview.tsx @@ -758,6 +758,7 @@ function LocationPickerColumn({ useEffect(() => { if (lastSourceRef.current === "map") { lastSourceRef.current = null + // eslint-disable-next-line react-hooks/set-state-in-effect -- debounced geocode keyed on the typed name: the map-originated skip branch and the below-2-chars branch clear now-stale suggestions; both bail out when suggestions are already empty setSuggestions([]) return } diff --git a/src/components/project/project-edit-route.tsx b/src/components/project/project-edit-route.tsx index afbcdff1..abe44e20 100644 --- a/src/components/project/project-edit-route.tsx +++ b/src/components/project/project-edit-route.tsx @@ -154,6 +154,7 @@ export default function ProjectEditRoute({ if (seededRef.current) return if (!project) return const v = project.value + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (seededRef) seeding of editable form state + swap baseline from the loaded project record; key-remount refactor tracked separately setTitle(asString(v.title)) setShortDescription(asString(v.shortDescription)) setDescription( @@ -198,26 +199,36 @@ export default function ProjectEditRoute({ ? r.record!.value.title.trim() : r.record!.uri.split("/").pop() ?? "(untitled activity)", })) + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (itemsSeededRef) hydration of the editable items list once useProjectItems resolutions settle; re-running would stomp user reordering/removal setItems(hydrated) itemsSeededRef.current = true }, [itemResolutions, itemsResolving, rawItems.length]) // Hydrate the location (single strongRef → AddedLocation) so the // edit form shows the existing place name + lets the user clear - // or replace it. - useEffect(() => { - if (!project) return - const locRef = project.value.location as { uri?: string; cid?: string } | undefined + // or replace it. The synchronous outcomes (no ref, unparsable URI) + // are adjusted during render when the record identity changes (keyed + // on uri|cid, not object identity, which isn't render-stable); only + // the getRecord name lookup lives in the effect. + const projectKey = project ? `${project.uri}|${project.cid}` : null + const [prevProjectKey, setPrevProjectKey] = useState(projectKey) + if (prevProjectKey !== projectKey) { + setPrevProjectKey(projectKey) + const locRef = project?.value.location as { uri?: string; cid?: string } | undefined if (!locRef?.uri || !locRef.cid) { setLocation(null) - return - } - let aborted = false - const parsed = parseAtUri(locRef.uri) - if (!parsed) { + } else if (!parseAtUri(locRef.uri)) { setLocation({ ref: { uri: locRef.uri, cid: locRef.cid }, name: locRef.uri }) - return } + } + + useEffect(() => { + if (!project) return + const locRef = project.value.location as { uri?: string; cid?: string } | undefined + if (!locRef?.uri || !locRef.cid) return + const parsed = parseAtUri(locRef.uri) + if (!parsed) return + let aborted = false const qs = new URLSearchParams({ repo: parsed.did, collection: parsed.collection, @@ -266,6 +277,7 @@ export default function ProjectEditRoute({ const SHORT_DESC_MAX = 300 useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- deliberate cross-field watcher clearing the save error on any edit; setError(null) bails out (no render) whenever error is already null setError(null) }, [ title, diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx index 449f5436..2d4424c4 100644 --- a/src/components/ui/theme-toggle.tsx +++ b/src/components/ui/theme-toggle.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React from "react"; import { useTheme } from "next-themes"; import { Monitor, Moon, Sun } from "lucide-react"; import { RadioGroup, Radio } from "./radio"; import Tooltip from "./tooltip"; +import { useMounted } from "@/hooks/use-mounted"; type ThemeValue = "light" | "dark" | "system"; @@ -56,11 +57,9 @@ export default function ThemeToggle({ className = "", }: ThemeToggleProps) { const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + // next-themes only knows the real theme on the client; render the + // undefined (system-default) state until hydration completes. + const mounted = useMounted(); const current = mounted ? (theme as ThemeValue | undefined) : undefined; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 08122728..1a36f924 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -8,6 +8,7 @@ import React, { useLayoutEffect, useRef, useState, + useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import { useMounted } from "@/hooks/use-mounted"; @@ -63,22 +64,26 @@ interface Coords { const GAP = 8; const PADDING = 8; +const HOVER_QUERY = "(hover: hover) and (pointer: fine)"; + +function subscribeHoverCapable(onChange: () => void): () => void { + const mq = globalThis.matchMedia?.(HOVER_QUERY); + if (!mq) return () => {}; + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); +} + /** * True once we know the primary pointer can actually hover (mouse / - * trackpad). Starts false — matching the SSR markup — and flips in an - * effect, so touch devices never mount the hover listeners at all. + * trackpad). The server snapshot is false — matching the SSR markup — + * so touch devices never mount the hover listeners at all. */ function useHoverCapable(): boolean { - const [capable, setCapable] = useState(false); - useEffect(() => { - const mq = globalThis.matchMedia?.("(hover: hover) and (pointer: fine)"); - if (!mq) return; - setCapable(mq.matches); - const onChange = (e: MediaQueryListEvent) => setCapable(e.matches); - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); - }, []); - return capable; + return useSyncExternalStore( + subscribeHoverCapable, + () => globalThis.matchMedia?.(HOVER_QUERY)?.matches ?? false, + () => false, + ); } function computeCoords( @@ -137,9 +142,12 @@ export default function Tooltip({ }, []); const show = useCallback(() => setOpen(true), []); + // hide() is the only path that sets open=false, so clearing the stale + // coords here keeps the bubble hidden-until-measured on the next open. const hide = useCallback(() => { clearTimer(); setOpen(false); + setCoords(null); }, [clearTimer]); const onPointerEnter = useCallback(() => { @@ -151,10 +159,7 @@ export default function Tooltip({ // Position when open; reposition on scroll/resize; hide on Esc. useLayoutEffect(() => { - if (!open) { - setCoords(null); - return; - } + if (!open) return; const reposition = () => { const trigger = wrapRef.current; const bubble = bubbleRef.current; diff --git a/src/components/workspace/workspace.tsx b/src/components/workspace/workspace.tsx index db18a884..3775f825 100644 --- a/src/components/workspace/workspace.tsx +++ b/src/components/workspace/workspace.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef } from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation" import LoadingSpinner from "@/components/ui/loading-spinner" import { Tabs, TabList, Tab, TabPanel } from "@/components/ui/tabs" @@ -83,17 +83,18 @@ export default function Workspace() { // Help with first-time-empty: if there's no `actor` param yet but // we have actors loaded, pick the first one as a default scope so - // the comparison surfaces start populated. - const [defaulted, setDefaulted] = useState(false) + // the comparison surfaces start populated. The latch is only read + // inside this effect, so a ref avoids a render. + const defaultedRef = useRef(false) useEffect(() => { - if (defaulted) return + if (defaultedRef.current) return if (scope.kind !== "network") return if (!actors.length) return - setDefaulted(true) + defaultedRef.current = true const params = new URLSearchParams(searchParams?.toString() ?? "") params.set("actor", actors[0].did) router.replace(`${pathname}?${params.toString()}`, { scroll: false }) - }, [actors, defaulted, scope.kind, searchParams, pathname, router]) + }, [actors, scope.kind, searchParams, pathname, router]) const setUrl = useCallback( (next: Partial<{ layout: LayoutKey; actor: string | null; lexicon: WorkspaceLexicon | null }>) => { diff --git a/src/hooks/use-activity-funding.ts b/src/hooks/use-activity-funding.ts index 9fa6a1af..318ca0e0 100644 --- a/src/hooks/use-activity-funding.ts +++ b/src/hooks/use-activity-funding.ts @@ -24,7 +24,7 @@ export function useActivityFunding( const { first = 100 } = options const [receipts, setReceipts] = useState([]) const [totalCount, setTotalCount] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(!!did && !!rkey) const [error, setError] = useState(null) // Bumped by `refetch()` to re-run the fetch effect — e.g. the "refresh" // affordance shown after recording, since the indexer is eventually @@ -32,22 +32,32 @@ export function useActivityFunding( const [refreshNonce, setRefreshNonce] = useState(0) const refetch = useCallback(() => setRefreshNonce((n) => n + 1), []) - useEffect(() => { - if (!did || !rkey) { + // Adjust state during render when the fetch identity changes (the nonce + // is part of the key so refetch() still flips isLoading). Stale receipts + // are kept while a same-target refetch is in flight, matching the old + // effect's behavior. + const fetchKey = `${did}|${rkey}|${first}|${refreshNonce}` + const [prevFetchKey, setPrevFetchKey] = useState(fetchKey) + if (prevFetchKey !== fetchKey) { + setPrevFetchKey(fetchKey) + if (did && rkey) { + setIsLoading(true) + setError(null) + } else { setReceipts([]) setTotalCount(null) setIsLoading(false) setError(null) - return } + } + + useEffect(() => { + if (!did || !rkey) return const forUri = `at://${did}/org.hypercerts.claim.activity/${rkey}` const controller = new AbortController() const { signal } = controller - setIsLoading(true) - setError(null) - fetchFundingReceiptsForActivity(forUri, { first, signal }) .then((result) => { if (signal.aborted) return diff --git a/src/hooks/use-author-info.ts b/src/hooks/use-author-info.ts index 798c5099..7bd3078e 100644 --- a/src/hooks/use-author-info.ts +++ b/src/hooks/use-author-info.ts @@ -40,17 +40,21 @@ export function useAuthorInfo(did: string | null): { const [isLoading, setIsLoading] = useState(!!did) const [error, setError] = useState(null) + // Reset to the initializer state during render when the DID changes + // (React's adjust-state-during-render pattern), so the effect only + // contains the fetch lifecycle. + const [prevDid, setPrevDid] = useState(did) + if (prevDid !== did) { + setPrevDid(did) + setInfo(null) + setIsLoading(!!did) + setError(null) + } + useEffect(() => { - if (!did) { - setInfo(null) - setIsLoading(false) - setError(null) - return - } + if (!did) return let cancelled = false - setIsLoading(true) - setError(null) fetchAuthor(did) .then((data) => { diff --git a/src/hooks/use-bottom-sheet-drag.ts b/src/hooks/use-bottom-sheet-drag.ts index 77bb0015..18c1536e 100644 --- a/src/hooks/use-bottom-sheet-drag.ts +++ b/src/hooks/use-bottom-sheet-drag.ts @@ -42,10 +42,11 @@ export function useBottomSheetDrag({ } }, []) - // Reset sheet expanded state when closed - useEffect(() => { - if (!isOpen) setSheetExpanded(false) - }, [isOpen]) + // Reset sheet expanded state when closed — adjusted during render so + // the collapse doesn't wait for an effect pass. + if (!isOpen && sheetExpanded) { + setSheetExpanded(false) + } // Auto-expand sheet when input is focused on mobile (keyboard opens) useEffect(() => { diff --git a/src/hooks/use-cert-projects.ts b/src/hooks/use-cert-projects.ts index 48c44f36..d1a8e065 100644 --- a/src/hooks/use-cert-projects.ts +++ b/src/hooks/use-cert-projects.ts @@ -38,24 +38,27 @@ interface ProjectsContainingCertResponse { export function useCertProjects(did: string | null, rkey: string | null) { const [projects, setProjects] = useState([]) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(!!did && !!rkey) const [error, setError] = useState(null) + // Adjust state during render when the cert identity changes, so the + // effect holds only the fetch lifecycle. + const certKey = `${did}|${rkey}` + const [prevCertKey, setPrevCertKey] = useState(certKey) + if (prevCertKey !== certKey) { + setPrevCertKey(certKey) + setProjects([]) + setIsLoading(!!did && !!rkey) + setError(null) + } + useEffect(() => { - if (!did || !rkey) { - setProjects([]) - setIsLoading(false) - setError(null) - return - } + if (!did || !rkey) return const certUri = `at://${did}/org.hypercerts.claim.activity/${rkey}` const controller = new AbortController() const signal = controller.signal - setIsLoading(true) - setError(null) - fetch(INDEXER_PROXY_URL, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/hooks/use-contributor-info.ts b/src/hooks/use-contributor-info.ts index 25f29f8d..213c1c5b 100644 --- a/src/hooks/use-contributor-info.ts +++ b/src/hooks/use-contributor-info.ts @@ -73,15 +73,20 @@ export function useContributorInfo(identity: string | null | undefined): { const [info, setInfo] = useState(null) const [isLoading, setIsLoading] = useState(isAtproto) + // Adjust state during render when the identity changes, so the effect + // holds only the fetch lifecycle. + const identityKey = `${trimmed}|${isAtproto}` + const [prevIdentityKey, setPrevIdentityKey] = useState(identityKey) + if (prevIdentityKey !== identityKey) { + setPrevIdentityKey(identityKey) + setInfo(null) + setIsLoading(isAtproto) + } + useEffect(() => { - if (!isAtproto) { - setInfo(null) - setIsLoading(false) - return - } + if (!isAtproto) return let cancelled = false - setIsLoading(true) fetchContributor(trimmed) .then((data) => { diff --git a/src/hooks/use-contributor-information-record.ts b/src/hooks/use-contributor-information-record.ts index fa0cffc8..b394e86d 100644 --- a/src/hooks/use-contributor-information-record.ts +++ b/src/hooks/use-contributor-information-record.ts @@ -82,14 +82,18 @@ export function useContributorInformationRecord(uri: string | null | undefined): ) const [isLoading, setIsLoading] = useState(enabled) + // Adjust state during render when the target URI changes, so the effect + // holds only the fetch lifecycle. + const [prevUri, setPrevUri] = useState(uri) + if (prevUri !== uri) { + setPrevUri(uri) + setRecord(null) + setIsLoading(enabled) + } + useEffect(() => { - if (!enabled || !uri) { - setRecord(null) - setIsLoading(false) - return - } + if (!enabled || !uri) return let cancelled = false - setIsLoading(true) fetchByUri(uri).then((r) => { if (cancelled) return setRecord(r) diff --git a/src/hooks/use-evaluator-endorsements.ts b/src/hooks/use-evaluator-endorsements.ts index debc336d..1c5a6ffc 100644 --- a/src/hooks/use-evaluator-endorsements.ts +++ b/src/hooks/use-evaluator-endorsements.ts @@ -23,16 +23,19 @@ export function useEvaluatorEndorsements(selected: Set) { const [isLoading, setIsLoading] = useState(selected.size > 0) const [error, setError] = useState(null) + // Adjust state during render when the selection changes, so the effect + // holds only the fetch lifecycle. + const [prevSelectedKey, setPrevSelectedKey] = useState(selectedKey) + if (prevSelectedKey !== selectedKey) { + setPrevSelectedKey(selectedKey) + if (selectedKey === "") setEndorsedDids(new Set()) + setIsLoading(selectedKey !== "") + setError(null) + } + useEffect(() => { - if (selectedKey === "") { - setEndorsedDids(new Set()) - setIsLoading(false) - setError(null) - return - } + if (selectedKey === "") return const controller = new AbortController() - setIsLoading(true) - setError(null) fetchEvaluatorEndorsedDids(selectedKey.split(","), controller.signal) .then((dids) => { if (controller.signal.aborted) return diff --git a/src/hooks/use-location.ts b/src/hooks/use-location.ts index d632e557..b3830cfa 100644 --- a/src/hooks/use-location.ts +++ b/src/hooks/use-location.ts @@ -52,9 +52,17 @@ export function useLocation(uri: string): { const [location, setLocation] = useState(null) const [isLoading, setIsLoading] = useState(true) + // Adjust state during render when the URI changes (first mount already + // starts loading via the initializer), so the effect holds only the + // fetch lifecycle. + const [prevUri, setPrevUri] = useState(uri) + if (prevUri !== uri) { + setPrevUri(uri) + setIsLoading(true) + } + useEffect(() => { let cancelled = false - setIsLoading(true) fetchLocation(uri) .then((data) => { if (cancelled) return diff --git a/src/hooks/use-mounted.ts b/src/hooks/use-mounted.ts index ec113b32..8034f2b5 100644 --- a/src/hooks/use-mounted.ts +++ b/src/hooks/use-mounted.ts @@ -1,15 +1,20 @@ "use client" -import { useEffect, useState } from "react" +import { useSyncExternalStore } from "react" + +// Never fires: mount state only changes once, at hydration, which +// useSyncExternalStore models via the server/client snapshot split. +const emptySubscribe = () => () => {} /** * Returns `true` only after the component has mounted on the client. * * Use to gate render of code that must NOT participate in SSR — typical * cases in this app are `createPortal(...)` calls and any read of - * `document` / `window` during render. With this hook, the first - * render returns `false` (matching the server render), the post-mount - * commit flips it to `true`, and the portal renders into the real DOM. + * `document` / `window` during render. The first render returns `false` + * (matching the server render via the server snapshot), and the + * post-hydration client snapshot flips it to `true`, so the portal + * renders into the real DOM. * * The standard pattern of `const [mounted, setMounted] = useState(false) * + useEffect(() => setMounted(true), [])` was duplicated across the @@ -17,9 +22,9 @@ import { useEffect, useState } from "react" * switcher menus, plus `mobile-sidebar`'s portal). Consolidate here. */ export function useMounted(): boolean { - const [mounted, setMounted] = useState(false) - useEffect(() => { - setMounted(true) - }, []) - return mounted + return useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ) } diff --git a/src/hooks/use-org-marker.ts b/src/hooks/use-org-marker.ts index 18d5ea06..7714c6a6 100644 --- a/src/hooks/use-org-marker.ts +++ b/src/hooks/use-org-marker.ts @@ -156,12 +156,21 @@ export function useOrgMarker(did: string | null): { ) const [refreshTick, setRefreshTick] = useState(0) + // Adjust state during render when the DID or refresh tick changes, + // re-running the cache-aware initializer expressions. The cache itself + // is only mutated in the effect (module-map mutation must not happen + // during render), so on a refresh the stale entry is still readable + // here — result keeps the pre-refresh value while isLoading flips true. + const markerKey = `${did}|${refreshTick}` + const [prevMarkerKey, setPrevMarkerKey] = useState(markerKey) + if (prevMarkerKey !== markerKey) { + setPrevMarkerKey(markerKey) + setResult(did ? cache.get(did) ?? initial() : initial()) + setIsLoading(did ? refreshTick > 0 || !cache.has(did) : false) + } + useEffect(() => { - if (!did) { - setResult(initial()) - setIsLoading(false) - return - } + if (!did) return // When the refresh tick changes, evict the cache so fetchOrgMarker // hits the network instead of returning the stale entry. Also drop any // in-flight promise for this DID: a concurrent mount's pending fetch @@ -171,14 +180,8 @@ export function useOrgMarker(did: string | null): { cache.delete(did) inFlight.delete(did) } - const cached = cache.get(did) - if (cached && refreshTick === 0) { - setResult(cached) - setIsLoading(false) - return - } + if (cache.has(did) && refreshTick === 0) return let cancelled = false - setIsLoading(true) fetchOrgMarker(did) .then((res) => { if (cancelled) return diff --git a/src/hooks/use-own-certs.ts b/src/hooks/use-own-certs.ts index d284a087..89065851 100644 --- a/src/hooks/use-own-certs.ts +++ b/src/hooks/use-own-certs.ts @@ -29,14 +29,23 @@ export function useOwnCerts(did: string | null): { isLoading: boolean } { const { activeOrg } = useOrg() + const sourceDid = activeOrg ? activeOrg.groupDid : did const [ownCerts, setOwnCerts] = useState([]) - const [isLoading, setIsLoading] = useState(true) + // No source repo means no fetch — isLoading must be false then, not + // stuck on a true initializer. + const [isLoading, setIsLoading] = useState(!!sourceDid) + + // Adjust state during render when the source repo changes, so the + // effect holds only the fetch lifecycle. + const [prevSourceDid, setPrevSourceDid] = useState(sourceDid) + if (prevSourceDid !== sourceDid) { + setPrevSourceDid(sourceDid) + setIsLoading(!!sourceDid) + } useEffect(() => { - const sourceDid = activeOrg ? activeOrg.groupDid : did if (!sourceDid) return const controller = new AbortController() - setIsLoading(true) const params = new URLSearchParams({ repo: sourceDid, collection: "org.hypercerts.claim.activity", @@ -84,7 +93,7 @@ export function useOwnCerts(did: string | null): { if (!controller.signal.aborted) setIsLoading(false) }) return () => controller.abort() - }, [did, activeOrg]) + }, [sourceDid]) return { ownCerts, isLoading } } diff --git a/src/hooks/use-profile-pds.ts b/src/hooks/use-profile-pds.ts index 7c1381e9..787d9053 100644 --- a/src/hooks/use-profile-pds.ts +++ b/src/hooks/use-profile-pds.ts @@ -26,20 +26,28 @@ export function useProfilePds(did: string | null): ProfilePdsResult { ) const [isLoading, setIsLoading] = useState(() => !!did && !cache.has(did)) + // Adjust state during render when the DID changes, re-running the + // cache-aware initializer expressions. Inflight-map bookkeeping stays + // in the effect (module-map mutation must not happen during render). + const [prevDid, setPrevDid] = useState(did) + if (prevDid !== did) { + setPrevDid(did) + setPdsUrl(did && cache.has(did) ? (cache.get(did) ?? null) : null) + setIsLoading(!!did && !cache.has(did)) + } + useEffect(() => { - if (!did) { - setPdsUrl(null) - setIsLoading(false) - return - } - if (cache.has(did)) { - setPdsUrl(cache.get(did) ?? null) - setIsLoading(false) - return - } + if (!did) return let cancelled = false - setIsLoading(true) - const promise = inflight.get(did) ?? resolvePdsUrl(did) + // A cache hit still flows through a resolved promise so a fill that + // races the mount (another component's resolve landing between our + // render and this effect) can't leave isLoading stuck true — the + // async sets bail out when nothing changed. + const promise = + inflight.get(did) ?? + (cache.has(did) + ? Promise.resolve(cache.get(did) ?? null) + : resolvePdsUrl(did)) inflight.set(did, promise) promise .then((url) => { diff --git a/src/hooks/use-rights.ts b/src/hooks/use-rights.ts index 6581bf9b..5c7e7a12 100644 --- a/src/hooks/use-rights.ts +++ b/src/hooks/use-rights.ts @@ -68,14 +68,18 @@ export function useRights(uri: string | null | undefined): { const [record, setRecord] = useState(null) const [isLoading, setIsLoading] = useState(!!uri) + // Adjust state during render when the URI changes, mirroring the + // initializers, so the effect holds only the fetch lifecycle. + const [prevUri, setPrevUri] = useState(uri) + if (prevUri !== uri) { + setPrevUri(uri) + setRecord(null) + setIsLoading(!!uri) + } + useEffect(() => { - if (!uri) { - setRecord(null) - setIsLoading(false) - return - } + if (!uri) return let cancelled = false - setIsLoading(true) fetchRights(uri) .then((data) => { if (cancelled) return diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 7ef48320..6177cd38 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -93,31 +93,34 @@ export function useSession(): { ); const [error, setError] = useState(null); + // Adjust state during render when the auth state flips, re-running the + // initializer expressions. Without this, long-lived components that + // mounted while signed in keep returning the previous user's + // handle/email after sign-out — the initial-state expressions only run + // on FRESH mounts. Also covers the cached-result fast path on sign-in. + const [prevIsAuthenticated, setPrevIsAuthenticated] = + useState(isAuthenticated); + if (prevIsAuthenticated !== isAuthenticated) { + setPrevIsAuthenticated(isAuthenticated); + setHandle(isAuthenticated ? (cachedResult?.handle ?? null) : null); + setEmail(isAuthenticated ? (cachedResult?.email ?? null) : null); + setError(null); + setIsLoading(isAuthenticated && cachedResult === null); + } + useEffect(() => { if (!isAuthenticated) { + // The module-cache clears must stay in the effect — mutating + // module state during render is impure. The state resets live in + // the render adjust above. cachedPromise = null; cachedResult = null; - // Without these, long-lived components that mounted while - // signed in keep returning the previous user's handle/email - // after sign-out — the initial-state expressions at the top - // of the hook only gate on `isAuthenticated` for FRESH mounts, - // not existing ones. - setHandle(null); - setEmail(null); - setError(null); - setIsLoading(false); - return; - } - - // Already have a cached result — use it immediately - if (cachedResult) { - setHandle(cachedResult.handle); - setEmail(cachedResult.email); - setIsLoading(false); return; } - setIsLoading(true); + // On a cache hit fetchSession() returns the already-resolved shared + // promise, so this also covers a fill that races mount-to-effect; + // the async sets bail out when nothing changed. fetchSession() .then((data) => { setHandle(data.handle); diff --git a/src/hooks/use-workspace.ts b/src/hooks/use-workspace.ts index b2b18c59..9d1b7e0e 100644 --- a/src/hooks/use-workspace.ts +++ b/src/hooks/use-workspace.ts @@ -31,11 +31,9 @@ export function useNetworkActors(): { const [isLoading, setIsLoading] = useState(!actorsCache) useEffect(() => { - if (actorsCache) { - setActors(actorsCache) - setIsLoading(false) - return - } + // Cache hit needs no state sync: this []-dep effect runs right after + // mount and the initializers above already read the same cache. + if (actorsCache) return const controller = new AbortController() if (!actorsInflight) { actorsInflight = fetchNetworkActors({ first: 30, signal: controller.signal }) @@ -70,16 +68,20 @@ export function useActorWorkspaceCounts(did: string | null): { isLoading: boolean } { const [counts, setCounts] = useState(EMPTY_COUNTS) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(!!did) + + // Adjust state during render when the actor changes, so the effect + // holds only the fetch lifecycle. + const [prevDid, setPrevDid] = useState(did) + if (prevDid !== did) { + setPrevDid(did) + setCounts(EMPTY_COUNTS) + setIsLoading(!!did) + } useEffect(() => { - if (!did) { - setCounts(EMPTY_COUNTS) - setIsLoading(false) - return - } + if (!did) return const controller = new AbortController() - setIsLoading(true) fetchActorWorkspaceCounts(did, controller.signal) .then((next) => { if (controller.signal.aborted) return diff --git a/src/lib/groups/use-org-limit.ts b/src/lib/groups/use-org-limit.ts index 2cc3067e..6884e2a1 100644 --- a/src/lib/groups/use-org-limit.ts +++ b/src/lib/groups/use-org-limit.ts @@ -12,18 +12,20 @@ export function useOrgCreationLimit() { const [selfCreatedCount, setSelfCreatedCount] = useState(null) const [isChecking, setIsChecking] = useState(true) + // Adjust state during render once the org list settles: with no + // signed-in DID there is nothing to check (don't sit on the spinner + // forever), with one the count fetch is about to start. + const limitKey = `${did}|${orgsLoading}` + const [prevLimitKey, setPrevLimitKey] = useState(limitKey) + if (prevLimitKey !== limitKey) { + setPrevLimitKey(limitKey) + if (!orgsLoading) setIsChecking(!!did) + } + useEffect(() => { // Wait while the org list is still resolving — `did` may arrive late. - if (orgsLoading) return - // Auth has settled but there's no signed-in DID. Don't sit in the - // "checking" state forever (which leaves consumers stuck on a spinner - // with no way out); resolve so they can render a recoverable state. - if (!did) { - setIsChecking(false) - return - } + if (orgsLoading || !did) return const controller = new AbortController() - setIsChecking(true) getSelfCreatedOrgCount(did, groups, controller.signal) .then((count) => { if (!controller.signal.aborted) setSelfCreatedCount(count) diff --git a/src/lib/onboarding/onboarding-context.tsx b/src/lib/onboarding/onboarding-context.tsx index fd546ed3..1523473a 100644 --- a/src/lib/onboarding/onboarding-context.tsx +++ b/src/lib/onboarding/onboarding-context.tsx @@ -96,15 +96,23 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { // open state. const [autoPoppedDid, setAutoPoppedDid] = useState(null) - // Re-fetch gate state whenever the active personal DID changes. - // Personal DID only — org sessions are out of scope (separate flow). - useEffect(() => { + // Adjust state during render for the signed-out branch — resetting to + // the initializer values must not wait for an effect pass. + const authKey = `${isAuthenticated}|${did}` + const [prevAuthKey, setPrevAuthKey] = useState(authKey) + if (prevAuthKey !== authKey) { + setPrevAuthKey(authKey) if (!isAuthenticated || !did) { setState(EMPTY_STATE) setIsOpen(false) setAutoPoppedDid(null) - return } + } + + // Re-fetch gate state whenever the active personal DID changes. + // Personal DID only — org sessions are out of scope (separate flow). + useEffect(() => { + if (!isAuthenticated || !did) return let cancelled = false void (async () => { try { @@ -144,6 +152,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { if (!state.hasBlueskyProfile) return if (isOnboardingDismissed(did)) return if (autoPoppedDid === did) return + // eslint-disable-next-line react-hooks/set-state-in-effect -- once-per-DID auto-popup decision fired when the async resolve-did gate data settles; the autoPoppedDid latch prevents re-fire and no user event exists to host this setAutoPoppedDid(did) setIsOpen(true) }, [ From 01690c2900a783e8a9fcc9538667eea5f487a720 Mon Sep 17 00:00:00 2001 From: holke <103380539+holkexyz@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:42:19 +0000 Subject: [PATCH 28/42] chore(cleanup): delete dead hooks, exports, and their orphaned tests use-display-profile / use-pending-awards-count / use-user-activities (zero production importers) removed with their test files; dead exports dropped from account-email, app-passwords, urls, activity-uri; parseLocationCoords de-exported; stale doc references fixed. Co-Authored-By: Claude Fable 5 --- ...e-pending-awards-count-logged-out.test.tsx | 41 ------ .../__tests__/use-user-activities.test.tsx | 137 ------------------ src/hooks/use-display-profile.ts | 55 ------- src/hooks/use-managed-projects.ts | 2 +- src/hooks/use-pending-awards-count.ts | 71 --------- src/hooks/use-user-activities.ts | 97 ------------- src/hooks/use-user-indexer-activities.ts | 2 +- src/lib/__tests__/urls.test.ts | 15 -- .../atproto/__tests__/activity-uri.test.ts | 27 +--- src/lib/atproto/account-email.ts | 22 +-- src/lib/atproto/activity-uri.ts | 9 -- src/lib/atproto/app-passwords.ts | 6 - src/lib/atproto/location.ts | 2 +- src/lib/urls.ts | 25 ---- 14 files changed, 7 insertions(+), 504 deletions(-) delete mode 100644 src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx delete mode 100644 src/hooks/__tests__/use-user-activities.test.tsx delete mode 100644 src/hooks/use-display-profile.ts delete mode 100644 src/hooks/use-pending-awards-count.ts delete mode 100644 src/hooks/use-user-activities.ts diff --git a/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx b/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx deleted file mode 100644 index 3b8506b1..00000000 --- a/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook, cleanup } from "@testing-library/react" - -// Logged-out state: the hook short-circuits before touching the -// responses fetch or the scan cache, so those collaborators only -// need inert stubs. useAuth drives the branch under test. -const authState = { did: null as string | null, isAuthenticated: false } - -vi.mock("@/lib/auth/auth-context", () => ({ - useAuth: () => authState, -})) - -vi.mock("@/hooks/use-profile-responses", () => ({ - useProfileResponses: () => ({ responses: [], isLoading: false }), -})) - -vi.mock("@/hooks/use-received-endorsements", () => ({ - peekCachedReceivedEndorsements: () => null, -})) - -import { usePendingAwardsCount } from "../use-pending-awards-count" - -beforeEach(() => { - cleanup() - authState.did = null - authState.isAuthenticated = false -}) - -describe("usePendingAwardsCount — logged-out contract", () => { - it("returns null (not 0) when logged out, matching the JSDoc", () => { - const { result } = renderHook(() => usePendingAwardsCount()) - expect(result.current).toBeNull() - }) - - it("returns null when authenticated but did is missing", () => { - authState.isAuthenticated = true - authState.did = null - const { result } = renderHook(() => usePendingAwardsCount()) - expect(result.current).toBeNull() - }) -}) diff --git a/src/hooks/__tests__/use-user-activities.test.tsx b/src/hooks/__tests__/use-user-activities.test.tsx deleted file mode 100644 index 1adcafc1..00000000 --- a/src/hooks/__tests__/use-user-activities.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook, act, waitFor, cleanup } from "@testing-library/react" -import type { - ActivityRecord, - ListActivitiesResponse, -} from "@/lib/atproto/activity-types" - -// Controllable mock of fetchActivities. Each call records its args and -// returns a deferred promise we resolve by hand so a test can interleave -// a profile switch between a fetch starting and resolving. -interface PendingCall { - did: string - cursor: string | undefined - resolve: (value: ListActivitiesResponse) => void -} - -const calls: PendingCall[] = [] - -vi.mock("@/lib/atproto/activity", () => ({ - fetchActivities: vi.fn( - (did: string, cursor?: string) => - new Promise((resolve) => { - calls.push({ did, cursor, resolve }) - }), - ), -})) - -import { useUserActivities } from "../use-user-activities" - -function rec(uri: string): ActivityRecord { - return { - uri, - cid: `cid-${uri}`, - value: { - title: uri, - shortDescription: "", - createdAt: "2026-01-01T00:00:00.000Z", - }, - } -} - -/** Resolve the oldest still-pending fetch matching the predicate. */ -function resolveCall( - match: (c: PendingCall) => boolean, - value: ListActivitiesResponse, -) { - const idx = calls.findIndex(match) - if (idx === -1) throw new Error("no matching pending fetchActivities call") - const [call] = calls.splice(idx, 1) - call.resolve(value) -} - -beforeEach(() => { - cleanup() - calls.length = 0 -}) - -describe("useUserActivities — loadMore generation guard", () => { - it("ignores an in-flight loadMore for the previous DID after a profile switch", async () => { - const { result, rerender } = renderHook(({ did }) => useUserActivities(did), { - initialProps: { did: "did:a" }, - }) - - // Page 1 for DID A. - await act(async () => { - resolveCall((c) => c.did === "did:a" && c.cursor === undefined, { - records: [rec("at://a/1"), rec("at://a/2")], - cursor: "cursor-a-1", - }) - }) - await waitFor(() => expect(result.current.activities).toHaveLength(2)) - - // Kick off loadMore for A — fetch starts but does NOT resolve yet. - act(() => { - result.current.loadMore() - }) - await waitFor(() => - expect(calls.some((c) => c.did === "did:a" && c.cursor === "cursor-a-1")).toBe(true), - ) - - // Switch to DID B and let its page-1 land, resetting the list to B's rows. - rerender({ did: "did:b" }) - await act(async () => { - resolveCall((c) => c.did === "did:b" && c.cursor === undefined, { - records: [rec("at://b/1")], - cursor: "cursor-b-1", - }) - }) - await waitFor(() => - expect(result.current.activities.map((r) => r.uri)).toEqual(["at://b/1"]), - ) - - // Now the stale loadMore for A resolves late. Its records must NOT be - // appended to B's reset list. - await act(async () => { - resolveCall((c) => c.did === "did:a" && c.cursor === "cursor-a-1", { - records: [rec("at://a/3"), rec("at://a/4")], - cursor: "cursor-a-2", - }) - }) - - const uris = result.current.activities.map((r) => r.uri) - expect(uris).toEqual(["at://b/1"]) - expect(uris).not.toContain("at://a/3") - expect(uris).not.toContain("at://a/4") - }) - - it("dedups appended records by uri on loadMore", async () => { - const { result } = renderHook(() => useUserActivities("did:a")) - - await act(async () => { - resolveCall((c) => c.did === "did:a" && c.cursor === undefined, { - records: [rec("at://a/1"), rec("at://a/2")], - cursor: "cursor-a-1", - }) - }) - await waitFor(() => expect(result.current.activities).toHaveLength(2)) - - act(() => { - result.current.loadMore() - }) - await waitFor(() => - expect(calls.some((c) => c.did === "did:a" && c.cursor === "cursor-a-1")).toBe(true), - ) - - // Page 2 overlaps page 1 (at://a/2 repeats across the cursor boundary). - await act(async () => { - resolveCall((c) => c.did === "did:a" && c.cursor === "cursor-a-1", { - records: [rec("at://a/2"), rec("at://a/3")], - cursor: null as unknown as undefined, - }) - }) - - const uris = result.current.activities.map((r) => r.uri) - expect(uris).toEqual(["at://a/1", "at://a/2", "at://a/3"]) - }) -}) diff --git a/src/hooks/use-display-profile.ts b/src/hooks/use-display-profile.ts deleted file mode 100644 index 225b3c51..00000000 --- a/src/hooks/use-display-profile.ts +++ /dev/null @@ -1,55 +0,0 @@ -"use client" - -import { useCallback, useEffect, useState } from "react" -import { - fetchDisplayProfile, - invalidateDisplayProfile, -} from "@/lib/atproto/hyperboard" -import type { DisplayProfileRecord } from "@/lib/atproto/hyperboard-types" - -export interface UseDisplayProfileResult { - profile: DisplayProfileRecord | null - isLoading: boolean - reload: () => void -} - -/** - * Load a user's own org.hyperboards.displayProfile (rkey self) — their - * self-declared appearance on contributor boards. Used by the displayProfile - * editor and as a fallback layer when rendering tiles. - */ -export function useDisplayProfile( - did: string | null | undefined, -): UseDisplayProfileResult { - const [profile, setProfile] = useState(null) - const [isLoading, setIsLoading] = useState(!!did) - const [reloadTick, setReloadTick] = useState(0) - - useEffect(() => { - if (!did) return - let cancelled = false - // Nest the state writes in an async runner so they read as a fetch - // lifecycle update rather than a synchronous effect-body cascade. - const run = async () => { - setIsLoading(true) - const p = await fetchDisplayProfile(did) - if (!cancelled) { - setProfile(p) - setIsLoading(false) - } - } - run() - return () => { - cancelled = true - } - // reloadTick re-runs the single cancellation-guarded loader (no stale - // write race from a second, unguarded fetch). - }, [did, reloadTick]) - - const reload = useCallback(() => { - if (did) invalidateDisplayProfile(did) - setReloadTick((t) => t + 1) - }, [did]) - - return { profile, isLoading, reload } -} diff --git a/src/hooks/use-managed-projects.ts b/src/hooks/use-managed-projects.ts index ce997c4a..06977fdb 100644 --- a/src/hooks/use-managed-projects.ts +++ b/src/hooks/use-managed-projects.ts @@ -57,7 +57,7 @@ export function useManagedProjects( // Bumped on every initial-load run so a late loadMore for a stale // author set can't append to a freshly reset list. Mirrors - // use-user-activities. + // use-explore. const generationRef = useRef(0) // Stable key so the effect only re-runs when the author DIDs actually diff --git a/src/hooks/use-pending-awards-count.ts b/src/hooks/use-pending-awards-count.ts deleted file mode 100644 index 3329aaa6..00000000 --- a/src/hooks/use-pending-awards-count.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useAuth } from "@/lib/auth/auth-context" -import { peekCachedReceivedEndorsements } from "@/hooks/use-received-endorsements" -import { useProfileResponses } from "@/hooks/use-profile-responses" -import { resolveResponseState } from "@/lib/atproto/badges" - -/** - * Count of un-responded (`default` or `unknown` state) awards on - * the viewer's profile. Drives the "pending" chip on the - * Endorsements nav item. - * - * **Read-only / passive.** Critically, this hook does NOT kick off - * the expensive fan-out scan (R2 I-1 in the round-1 impl review). - * The nav rail renders on every authenticated page, so triggering - * the scan from here would balloon the perf cost surface from "the - * user visits /endorsements" to "the user opens any page in the - * app." Instead we peek at the cached scan result; the chip stays - * hidden until something else populates the cache — typically when - * the user actually visits /endorsements or their own profile. - * - * Reactivity is driven by `useProfileResponses` (which DOES use a - * useSyncExternalStore-backed module store), so when a write - * invalidates responses we get a re-render and the chip count - * updates. The scan cache populates separately when the user lands - * on a surface that needs it. - * - * Returns null when: - * - logged out (chip should hide) - * - the scan cache is cold (chip should hide rather than show 0) - * - the responses fetch is still loading - * - * Returns 0 when everything's loaded and there are no pending - * awards (chip should still hide — formatter rule). - */ -export function usePendingAwardsCount(): number | null { - const { did, isAuthenticated } = useAuth() - // useProfileResponses fetches the viewer's responses (a single - // cheap listRecords). Acceptable on the nav rail — the cost is - // one round-trip; the scan was the expensive part. - const { responses, isLoading: respLoading } = useProfileResponses(did) - - // Peek the scan cache reactively. We track it in local state and - // refresh on focus + on every render of the nav rail (which - // happens on route changes). This keeps the chip up-to-date - // without ever triggering a fetch from the nav. - const [scanResult, setScanResult] = useState(() => - peekCachedReceivedEndorsements(did), - ) - useEffect(() => { - setScanResult(peekCachedReceivedEndorsements(did)) - // Re-check on focus — if the user visited /endorsements in a - // different tab while this tab was idle, the scan there - // populated the shared module cache; on return we see it. - const onFocus = () => - setScanResult(peekCachedReceivedEndorsements(did)) - window.addEventListener("focus", onFocus) - return () => window.removeEventListener("focus", onFocus) - }, [did]) - - if (!isAuthenticated || !did) return null - if (!scanResult || respLoading) return null - - let count = 0 - for (const e of scanResult) { - const { state } = resolveResponseState(e.uri, responses) - if (state === "default" || state === "unknown") count++ - } - return count -} diff --git a/src/hooks/use-user-activities.ts b/src/hooks/use-user-activities.ts deleted file mode 100644 index 0f31330a..00000000 --- a/src/hooks/use-user-activities.ts +++ /dev/null @@ -1,97 +0,0 @@ -"use client" - -import { useState, useEffect, useCallback, useRef } from "react" -import { fetchActivities } from "@/lib/atproto/activity" -import type { ActivityRecord } from "@/lib/atproto/activity-types" - -/** - * Fetch the activities of a specific user by DID. Takes an explicit - * `did` parameter so it can power any profile (own or someone else's). - */ -export function useUserActivities(did: string | null) { - const [activities, setActivities] = useState([]) - const [cursor, setCursor] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const [error, setError] = useState(null) - - // Bumped on every initial-load run (profile switch / remount). loadMore - // captures the generation it was issued under and bails if a fresh load - // has superseded it, so an in-flight loadMore for the previous DID can't - // append stale records to the reset list. Mirrors use-explore. - const generationRef = useRef(0) - - const loadInitial = useCallback( - async (signal?: AbortSignal) => { - const generation = ++generationRef.current - if (!did) { - setActivities([]) - setCursor(null) - setIsLoading(false) - setError(null) - return - } - - try { - setIsLoading(true) - setError(null) - const data = await fetchActivities(did, undefined, 20, signal) - if (signal?.aborted || generation !== generationRef.current) return - setActivities(data.records) - setCursor(data.cursor ?? null) - } catch (err) { - if (signal?.aborted || generation !== generationRef.current) return - console.error("Failed to fetch user activities:", err) - setError(err instanceof Error ? err.message : "Failed to fetch activities") - } finally { - if (!signal?.aborted) setIsLoading(false) - } - }, - [did] - ) - - useEffect(() => { - const controller = new AbortController() - loadInitial(controller.signal) - return () => controller.abort() - }, [loadInitial]) - - const loadMore = useCallback(async () => { - if (!did || !cursor || isLoadingMore) return - - // Capture the generation this page was requested under. If a profile - // switch (or remount) bumps the generation while this fetch is in - // flight, drop the result instead of appending the previous DID's - // records to the freshly reset list. - const generation = generationRef.current - - try { - setIsLoadingMore(true) - const data = await fetchActivities(did, cursor, 20) - if (generation !== generationRef.current) return - setActivities((prev) => { - // Dedup by uri so overlapping edges across a cursor boundary - // don't insert duplicate rows. Mirrors use-home-feed's `seen` Set. - const seen = new Set(prev.map((r) => r.uri)) - const append = data.records.filter((r) => !seen.has(r.uri)) - return [...prev, ...append] - }) - setCursor(data.cursor ?? null) - } catch (err) { - if (generation !== generationRef.current) return - console.error("Failed to load more user activities:", err) - setError(err instanceof Error ? err.message : "Failed to load more") - } finally { - if (generation === generationRef.current) setIsLoadingMore(false) - } - }, [did, cursor, isLoadingMore]) - - return { - activities, - isLoading, - isLoadingMore, - error, - hasMore: !!cursor, - loadMore, - } -} diff --git a/src/hooks/use-user-indexer-activities.ts b/src/hooks/use-user-indexer-activities.ts index e176a229..fdb92916 100644 --- a/src/hooks/use-user-indexer-activities.ts +++ b/src/hooks/use-user-indexer-activities.ts @@ -220,7 +220,7 @@ function mergeBucket( // Drop records already present from a prior page (the indexer can // return an overlapping edge across a cursor boundary), preventing // duplicate rows and duplicate React keys. Matches the sibling - // pagination hooks (use-user-activities, use-home-feed, use-explore). + // pagination hooks (use-home-feed, use-explore). const seen = new Set(prev.records.map((r) => r.uri)) const append = data.records.filter((r) => !seen.has(r.uri)) return { diff --git a/src/lib/__tests__/urls.test.ts b/src/lib/__tests__/urls.test.ts index b2208e79..910bbd65 100644 --- a/src/lib/__tests__/urls.test.ts +++ b/src/lib/__tests__/urls.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect } from "vitest" import { profileUrl, recordUrl, - activityUrl, - projectUrl, parseActor, isDid, isRecordType, @@ -13,8 +11,6 @@ import { buildAtUri, parsePastedAtUri, recordUrlFromAtUri, - shareRecordUrl, - shareProfileUrl, RESERVED_ROUTES, } from "@/lib/urls" @@ -31,17 +27,6 @@ describe("builders", () => { expect(recordUrl("did:plc:abc", "project", "rk")).toBe( "/did:plc:abc/project/rk", ) - expect(activityUrl("alice.eco", "r")).toBe("/alice.eco/activity/r") - expect(projectUrl("alice.eco", "r")).toBe("/alice.eco/project/r") - }) - - it("share* builders force the DID form with an absolute origin", () => { - expect(shareProfileUrl("did:plc:abc", "https://certified.app")).toBe( - "https://certified.app/did:plc:abc", - ) - expect( - shareRecordUrl("did:plc:abc", "activity", "rk", "https://certified.app"), - ).toBe("https://certified.app/did:plc:abc/activity/rk") }) }) diff --git a/src/lib/atproto/__tests__/activity-uri.test.ts b/src/lib/atproto/__tests__/activity-uri.test.ts index f2b1ebf0..31349433 100644 --- a/src/lib/atproto/__tests__/activity-uri.test.ts +++ b/src/lib/atproto/__tests__/activity-uri.test.ts @@ -3,7 +3,6 @@ import { parseAtUri, parseActivityUri, activityDetailHref, - activityDetailHrefFromUri, } from "../activity-uri" describe("parseAtUri", () => { @@ -76,8 +75,8 @@ describe("parseActivityUri", () => { }) it("does NOT enforce that the collection matches activity NSID", () => { - // parseActivityUri is structurally identical to parseAtUri — the - // collection guard lives in activityDetailHrefFromUri instead. + // parseActivityUri is structurally identical to parseAtUri — callers + // that need an activity-only guard check the collection themselves. expect( parseActivityUri("at://did:plc:abc/some.other.collection/rkey1"), ).toEqual({ @@ -95,25 +94,3 @@ describe("activityDetailHref", () => { ) }) }) - -describe("activityDetailHrefFromUri", () => { - it("returns the detail href for a valid activity URI", () => { - expect( - activityDetailHrefFromUri( - "at://did:plc:abc/org.hypercerts.claim.activity/rkey1", - ), - ).toBe("/did:plc:abc/activity/rkey1") - }) - - it("returns null when the collection is not the activity NSID", () => { - expect( - activityDetailHrefFromUri( - "at://did:plc:abc/org.hypercerts.collection/rkey1", - ), - ).toBeNull() - }) - - it("returns null when the URI is malformed", () => { - expect(activityDetailHrefFromUri("not-an-at-uri")).toBeNull() - }) -}) diff --git a/src/lib/atproto/account-email.ts b/src/lib/atproto/account-email.ts index cd4a77e3..2bae4928 100644 --- a/src/lib/atproto/account-email.ts +++ b/src/lib/atproto/account-email.ts @@ -10,22 +10,15 @@ import { authFetch } from "@/lib/auth/fetch" * passwords also unlocks email and vice-versa. * * Flow: - * 1. `readEmail()` returns the current address (or throws `EmailLockedError` - * when no session is open, so the UI shows the unlock gate). - * 2. `requestEmailUpdate()` asks the PDS to start a change. If the current + * 1. `requestEmailUpdate()` asks the PDS to start a change. If the current * email is confirmed the PDS emails a code and returns `tokenRequired`. - * 3. `updateEmail(email, token?)` commits the new address (with the code when + * 2. `updateEmail(email, token?)` commits the new address (with the code when * one was required). * * Each call throws `EmailLockedError` when the route reports * `401 { error: "locked" }`, so the UI can drop back to the unlock step. */ -export interface EmailInfo { - email: string | null - emailConfirmed: boolean -} - /** * Thrown when the route reports `401 { error: "locked" }` — there's no elevated * session (never opened, or the ~10-minute TTL lapsed). The UI catches this to @@ -59,17 +52,6 @@ async function errorMessage(res: Response, fallback: string): Promise { } } -export async function readEmail(signal?: AbortSignal): Promise { - const res = await authFetch(BASE, { signal }) - if (await isLocked(res)) throw new EmailLockedError() - if (!res.ok) throw new Error(await errorMessage(res, "Failed to read email")) - const data = (await res.json()) as Partial - return { - email: data.email ?? null, - emailConfirmed: !!data.emailConfirmed, - } -} - /** * Ask the PDS to start an email change. Returns whether a code was emailed to * the current address (`tokenRequired`); pass that code to `updateEmail`. diff --git a/src/lib/atproto/activity-uri.ts b/src/lib/atproto/activity-uri.ts index 7d7947b1..d95bf2a6 100644 --- a/src/lib/atproto/activity-uri.ts +++ b/src/lib/atproto/activity-uri.ts @@ -7,7 +7,6 @@ import { parseAtUri as parseAtUriImpl, recordUrl, - typeForCollection, type ParsedAtUri, } from "@/lib/urls" @@ -29,11 +28,3 @@ export function parseActivityUri(uri: string): ParsedActivityUri | null { export function activityDetailHref(did: string, rkey: string): string { return recordUrl(did, "activity", rkey) } - -/** Convenience: go from an at:// URI straight to the detail URL. */ -export function activityDetailHrefFromUri(uri: string): string | null { - const parsed = parseAtUriImpl(uri) - if (!parsed) return null - if (typeForCollection(parsed.collection) !== "activity") return null - return activityDetailHref(parsed.did, parsed.rkey) -} diff --git a/src/lib/atproto/app-passwords.ts b/src/lib/atproto/app-passwords.ts index 2a4faa07..9745405e 100644 --- a/src/lib/atproto/app-passwords.ts +++ b/src/lib/atproto/app-passwords.ts @@ -17,7 +17,6 @@ import { authFetch } from "@/lib/auth/fetch" * 2. `list` / `create` / `revoke` operate inside that session. Each throws * `AppPasswordsLockedError` when the session is gone (TTL expiry), so * the UI can drop back to the locked gate. - * 3. `lockAppPasswords()` tears the session down. * * `createAppPassword` returns the generated secret EXACTLY ONCE — the PDS * never reveals it again — so the caller must surface it for copy @@ -97,11 +96,6 @@ export async function unlockAppPasswords( throw new Error("Unexpected response from the unlock service.") } -/** Tear down the elevated session (best-effort `deleteSession` + clear). */ -export async function lockAppPasswords(): Promise { - await authFetch(`${BASE}/session`, { method: "DELETE" }) -} - export async function listAppPasswords( signal?: AbortSignal, ): Promise { diff --git a/src/lib/atproto/location.ts b/src/lib/atproto/location.ts index 704620dd..dc66e84a 100644 --- a/src/lib/atproto/location.ts +++ b/src/lib/atproto/location.ts @@ -77,7 +77,7 @@ function extractLocationString(location: unknown): string | null { /** Try to parse common coordinate formats into a `{lat, lng}` pair. * Returns null if the format isn't supported or the value is malformed. */ -export function parseLocationCoords( +function parseLocationCoords( locationType: string | undefined, location: unknown ): LatLng | null { diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 22f699ab..c896f9a1 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -127,14 +127,6 @@ export function recordUrl(actor: string, type: RecordType, rkey: string): string return `/${actor}/${type}/${rkey}` } -/** Convenience builders for the two concrete record types. */ -export function activityUrl(actor: string, rkey: string): string { - return recordUrl(actor, "activity", rkey) -} -export function projectUrl(actor: string, rkey: string): string { - return recordUrl(actor, "project", rkey) -} - /** * In-app link to a single typed list on the owner's profile. Typed * lists (`list:projects` / `list:accounts` / `list:certs`) don't get @@ -160,23 +152,6 @@ export function recordUrlFromAtUri(uri: string, actor?: string): string | null { return recordUrl(actor ?? parsed.did, type, parsed.rkey) } -// --- Durable (share) variants — always the DID form ------------------------ - -/** Absolute, DID-based profile URL for sharing. */ -export function shareProfileUrl(did: string, origin: string): string { - return `${origin}${profileUrl(did)}` -} - -/** Absolute, DID-based record URL for sharing. */ -export function shareRecordUrl( - did: string, - type: RecordType, - rkey: string, - origin: string, -): string { - return `${origin}${recordUrl(did, type, rkey)}` -} - // --------------------------------------------------------------------------- // AT-URI parsing (absorbs the old src/lib/atproto/activity-uri.ts helpers) // --------------------------------------------------------------------------- From 346cef9edd2dd7f45ff86a7759f7896337cb4d47 Mon Sep 17 00:00:00 2001 From: holke <103380539+holkexyz@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:42:19 +0000 Subject: [PATCH 29/42] fix(onboarding,tour): key-remount modal seeding; clamp tour step on viewport shrink Onboarding modal remounts per open via key so draft seeding is initialization instead of a set-state-in-effect (also stops the bsky-graph sync mounting on every page while closed); tour context exposes a clamped stepIndex and clamps back() so a mid-tour resize across 800px cannot strand the step index past the shorter steps array; covered by new tour-context tests. Co-Authored-By: Claude Fable 5 --- .../onboarding/onboarding-modal.tsx | 120 ++++++++++------- .../onboarding/steps/step-profile.tsx | 14 +- src/lib/tour/__tests__/tour-context.test.tsx | 127 ++++++++++++++++++ src/lib/tour/tour-context.tsx | 36 ++--- 4 files changed, 222 insertions(+), 75 deletions(-) create mode 100644 src/lib/tour/__tests__/tour-context.test.tsx diff --git a/src/components/onboarding/onboarding-modal.tsx b/src/components/onboarding/onboarding-modal.tsx index 60c4c4cf..ea21e845 100644 --- a/src/components/onboarding/onboarding-modal.tsx +++ b/src/components/onboarding/onboarding-modal.tsx @@ -1,17 +1,17 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { profileUrl } from "@/lib/urls" import AppDialog from "@/components/ui/app-dialog" import Button from "@/components/ui/button" import { useAuth } from "@/lib/auth/auth-context" import { useSession } from "@/hooks/use-session" -import { useOnboarding } from "@/lib/onboarding/onboarding-context" +import { + useOnboarding, + type BlueskySeed, +} from "@/lib/onboarding/onboarding-context" import { useSocialGraphSync } from "@/hooks/use-social-graph-sync" -import StepProfile, { - type ProfileDraft, - emptyProfileDraft, -} from "./steps/step-profile" +import StepProfile, { type ProfileDraft } from "./steps/step-profile" import StepGraph, { type GraphIntent } from "./steps/step-graph" import { useOnboardingCommit, @@ -41,6 +41,10 @@ const STEP_TITLES: Record = { * screen. The footer's primary button is dual-purpose: on Step 1 * it's "Continue", on Step 2 it labels the actual finish action * based on the selected sync intent. + * + * The form child mounts fresh per open and per DID (key={did}), so + * its useState initializers do the bsky seeding — every open starts + * from the seed, and a draft can never leak across account switches. */ export default function OnboardingModal() { const { isOpen, bskySeed, dismissOnboarding, completeOnboarding } = @@ -48,50 +52,63 @@ export default function OnboardingModal() { const { did } = useAuth() const { handle } = useSession() - const [step, setStep] = useState("profile") - const [profileDraft, setProfileDraft] = useState(() => - emptyProfileDraft(), + if (!isOpen) return null + if (!did) return null + + return ( + ) +} + +interface OnboardingModalContentProps { + readonly did: string + readonly handle: string | null + readonly bskySeed: BlueskySeed | null + /** User skipped or closed the modal (sets the dismissed sentinel). */ + onDismiss: () => void + /** Commit pipeline finished successfully. */ + onComplete: () => void +} + +function OnboardingModalContent({ + did, + handle, + bskySeed, + onDismiss, + onComplete, +}: OnboardingModalContentProps) { + const [step, setStep] = useState("profile") + // Seeded once at mount from the bsky values available when the modal + // opened (the auto-popup only fires after resolve-did settles). + const [profileDraft, setProfileDraft] = useState(() => ({ + displayName: bskySeed?.displayName?.trim() ?? "", + description: bskySeed?.description?.trim() ?? "", + website: "", + sourceAvatarUrl: bskySeed?.avatar ?? null, + sourceBannerUrl: bskySeed?.banner ?? null, + replacementAvatarFile: null, + replacementBannerFile: null, + })) const [graphIntent, setGraphIntent] = useState({ kind: "all", }) const [selected, setSelected] = useState>(() => new Set()) - // Seed the profile draft from bsky values the first time the modal - // opens for this DID. Subsequent re-opens (banner clicks) keep - // whatever the user already typed. - const seededForDid = useRef(null) - useEffect(() => { - if (!isOpen) return - if (!did) return - if (seededForDid.current === did) return - seededForDid.current = did - setProfileDraft({ - displayName: bskySeed?.displayName?.trim() ?? "", - description: bskySeed?.description?.trim() ?? "", - website: "", - sourceAvatarUrl: bskySeed?.avatar ?? null, - sourceBannerUrl: bskySeed?.banner ?? null, - replacementAvatarFile: null, - replacementBannerFile: null, - }) - setGraphIntent({ kind: "all" }) - setSelected(new Set()) - setStep("profile") - }, [isOpen, did, bskySeed]) - - useEffect(() => { - if (!isOpen) seededForDid.current = null - }, [isOpen]) - // Lifted to the modal so Step 2 can render stats inline and the // commit pipeline shares the same hook instance during in-place // imports (no double-fetch). - const sync = useSocialGraphSync(did ?? "", { ownDid: did ?? "" }) + const sync = useSocialGraphSync(did, { ownDid: did }) const commit = useOnboardingCommit({ did, - onSuccess: completeOnboarding, + onSuccess: onComplete, }) const runCommit = useCallback( @@ -138,20 +155,30 @@ export default function OnboardingModal() { const handleClose = useCallback(() => { if (commit.state.status === "running") return - dismissOnboarding() - }, [commit.state.status, dismissOnboarding]) + onDismiss() + }, [commit.state.status, onDismiss]) - if (!isOpen) return null - if (!did) return null + // Object URL for a replacement avatar, revoked on change/unmount — + // creating it inline in the success render leaked one URL per + // re-render. + const replacementAvatarUrl = useMemo( + () => + profileDraft.replacementAvatarFile + ? URL.createObjectURL(profileDraft.replacementAvatarFile) + : null, + [profileDraft.replacementAvatarFile], + ) + useEffect(() => { + return () => { + if (replacementAvatarUrl) URL.revokeObjectURL(replacementAvatarUrl) + } + }, [replacementAvatarUrl]) // Success state takes over the whole modal — no steps, no body, // just celebration. Button does a full reload so resolve-did's // 10s own-DID cache doesn't serve stale data to the profile page. if (commit.state.status === "success") { - const previewUrl = - (profileDraft.replacementAvatarFile - ? URL.createObjectURL(profileDraft.replacementAvatarFile) - : profileDraft.sourceAvatarUrl) || null + const previewUrl = replacementAvatarUrl || profileDraft.sourceAvatarUrl || null const goToProfile = () => { const target = handle ? profileUrl(handle) : "/" window.location.assign(target) @@ -165,6 +192,7 @@ export default function OnboardingModal() { >
{previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- previewUrl is a blob: object URL (picked file) or an arbitrary bsky-CDN avatar; next/image supports neither (remotePatterns is limited to **.certified.app) void @@ -90,6 +78,7 @@ export default function StepProfile({
{previewBannerUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- previewBannerUrl is a blob: object URL or an arbitrary remote bsky banner; next/image supports neither (remotePatterns is limited to **.certified.app) {previewAvatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- previewAvatarUrl is a blob: object URL or an arbitrary remote bsky avatar; same remotePatterns constraint as the banner above ({ isDesktop: true })) + +vi.mock("@/hooks/use-layout-breakpoints", () => ({ + useLayoutBreakpoints: () => ({ isDesktop: layout.isDesktop }), +})) + +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ isAuthenticated: true, did: "did:plc:tour-clamp-test" }), +})) + +vi.mock("@/lib/groups/org-context", () => ({ + useOrg: () => ({ activeOrg: null }), +})) + +// No pending/completed sentinel state — the tour only runs via start(). +vi.mock("../tour-sentinel", () => ({ + isTourCompleted: () => false, + markTourCompleted: vi.fn(), + isTourPending: () => false, + clearTourPending: vi.fn(), +})) + +// Minimal script: 5 steps on desktop (s1 d1 d2 d3 s2), 3 on mobile +// (s1 m1 s2) — flipping desktop→mobile mid-tour shrinks the array. +vi.mock("../tour-steps", () => { + const mk = (id: string, platform?: "desktop" | "mobile") => ({ + id, + navigateTo: null, + anchor: null, + title: id, + body: id, + ...(platform ? { platform } : {}), + }) + return { + TOUR_STEPS: [ + mk("s1"), + mk("d1", "desktop"), + mk("d2", "desktop"), + mk("d3", "desktop"), + mk("m1", "mobile"), + mk("s2"), + ], + } +}) + +import { TourProvider, useTour } from "../tour-context" + +beforeEach(() => { + cleanup() + layout.isDesktop = true +}) + +describe("TourProvider — steps array shrinks mid-tour", () => { + it("exposes a clamped stepIndex so the last step reads as last", () => { + const { result, rerender } = renderHook(() => useTour(), { + wrapper: TourProvider, + }) + + act(() => result.current.start()) + act(() => { + result.current.next() + result.current.next() + result.current.next() + result.current.next() + }) + expect(result.current.totalSteps).toBe(5) + expect(result.current.stepIndex).toBe(4) + expect(result.current.step?.id).toBe("s2") + + layout.isDesktop = false + rerender() + + // Stored index 4 overflows the 3-step mobile array; the exposed + // index must clamp to the new last step, keeping the card visible + // and the isLast check (stepIndex === totalSteps - 1) truthful. + expect(result.current.isActive).toBe(true) + expect(result.current.totalSteps).toBe(3) + expect(result.current.stepIndex).toBe(2) + expect(result.current.step?.id).toBe("s2") + expect(result.current.stepIndex).toBe(result.current.totalSteps - 1) + }) + + it("back() from an overflowed index steps once, not per overflow click", () => { + const { result, rerender } = renderHook(() => useTour(), { + wrapper: TourProvider, + }) + + act(() => result.current.start()) + act(() => { + result.current.next() + result.current.next() + result.current.next() + result.current.next() + }) + + layout.isDesktop = false + rerender() + expect(result.current.stepIndex).toBe(2) + + // One click must land on the step before the clamped position — + // not silently burn clicks walking 4 → 3 → 2 inside the store. + act(() => result.current.back()) + expect(result.current.stepIndex).toBe(1) + expect(result.current.step?.id).toBe("m1") + + act(() => result.current.back()) + expect(result.current.stepIndex).toBe(0) + expect(result.current.step?.id).toBe("s1") + + // Clamped at the first step. + act(() => result.current.back()) + expect(result.current.stepIndex).toBe(0) + }) +}) diff --git a/src/lib/tour/tour-context.tsx b/src/lib/tour/tour-context.tsx index 0bc5eab9..e38ff6f4 100644 --- a/src/lib/tour/tour-context.tsx +++ b/src/lib/tour/tour-context.tsx @@ -23,7 +23,9 @@ import { export interface TourContextValue { /** True while the walk-through is running. */ readonly isActive: boolean - /** Index into TOUR_STEPS of the current step (0-based). */ + /** Index into the platform-filtered steps of the current step + * (0-based). Always clamped into range, even right after a layout + * flip shrinks the steps array. */ readonly stepIndex: number /** The current step, or null when inactive. */ readonly step: TourStep | null @@ -70,6 +72,7 @@ export function TourProvider({ children }: { children: ReactNode }) { // Reset everything when the signed-in identity goes away. useEffect(() => { if (!isAuthenticated || !did) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset tour state on sign-out (external auth input); all three setStates bail out when already reset setIsActive(false) setStepIndex(0) setAutoCheckedDid(null) @@ -84,6 +87,7 @@ export function TourProvider({ children }: { children: ReactNode }) { if (!isAuthenticated || !did) return if (activeOrg) return if (autoCheckedDid === did) return + // eslint-disable-next-line react-hooks/set-state-in-effect -- once-per-DID auto-start decision (pending-flag check + clear) on auth/org settle; the autoCheckedDid latch prevents re-fire setAutoCheckedDid(did) if (isTourPending(did) && !isTourCompleted(did)) { clearTourPending(did) @@ -92,16 +96,6 @@ export function TourProvider({ children }: { children: ReactNode }) { } }, [isAuthenticated, did, activeOrg, autoCheckedDid]) - // If the layout flips mid-tour (crossing 800px swaps the desktop/mobile - // nav steps, which can differ in length), clamp the active step so it - // never points past the new end — otherwise `step` resolves to null and - // the tour card silently vanishes while still "active". A no-op when the - // index is already in range (React bails out of the identical state). - useEffect(() => { - if (!isActive) return - setStepIndex((i) => Math.min(i, Math.max(0, steps.length - 1))) - }, [steps.length, isActive]) - const start = useCallback(() => { setStepIndex(0) setIsActive(true) @@ -120,18 +114,26 @@ export function TourProvider({ children }: { children: ReactNode }) { setStepIndex((i) => Math.min(steps.length - 1, i + 1)) }, [steps.length]) + // Clamp against the current steps array so stepping back from an + // index that overflowed after a layout flip lands on the new last + // step instead of appearing dead for several clicks. const back = useCallback(() => { - setStepIndex((i) => Math.max(0, i - 1)) - }, []) + setStepIndex((i) => Math.max(0, Math.min(i, steps.length - 1) - 1)) + }, [steps.length]) + // If the layout flips mid-tour (crossing 800px swaps the desktop/mobile + // nav steps, which can differ in length), the stored index can point + // past the new end. Expose a clamped index instead of storing one — + // every consumer (progress dots, "Step N of M", isLast) sees an + // in-range value with no corrective re-render. `next` self-heals via + // its own Math.min and `back` clamps above. const value = useMemo(() => { + const effectiveStepIndex = Math.min(stepIndex, Math.max(0, steps.length - 1)) const step = - isActive && stepIndex >= 0 && stepIndex < steps.length - ? steps[stepIndex] - : null + isActive && steps.length > 0 ? steps[effectiveStepIndex] : null return { isActive, - stepIndex, + stepIndex: effectiveStepIndex, step, totalSteps: steps.length, start, From 1e9e37c6f9eca57eeed703637d6286ef82e91ec6 Mon Sep 17 00:00:00 2001 From: holke <103380539+holkexyz@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:42:19 +0000 Subject: [PATCH 30/42] style(css): tokenize z-index/shadows/status colors, remove dead CSS, contain long lists All literal z-index rules mapped onto the --z-* scale (stacking table in findings.md; new --z-local-raise + --z-skip-nav tokens); error focus ring and status colors tokenized (dark mode now legible); --shadow-modal token replaces three literal copies; 2px radius literals -> var(--radius); ~640 lines of orphaned CSS removed (live compound-selector partners preserved); content-visibility + contain-intrinsic-size on home-feed and explore lists; app-dialog latest-ref writes moved into an effect (react-hooks/refs errors surfaced when the stale disable directive was removed). Co-Authored-By: Claude Fable 5 --- src/app/styles/cert-detail.css | 13 +- src/app/styles/components.css | 347 +----------------------- src/app/styles/explore.css | 15 + src/app/styles/feed.css | 275 +------------------ src/app/styles/home.css | 6 + src/app/styles/landing.css | 62 +---- src/app/styles/layout.css | 121 +-------- src/app/styles/pages.css | 31 +-- src/app/styles/profile-edit.css | 19 +- src/app/styles/profile-endorsements.css | 4 +- src/app/styles/profile-groups.css | 4 +- src/app/styles/profile-inline-edit.css | 4 +- src/app/styles/profile-lists.css | 2 +- src/app/styles/profile.css | 17 +- src/app/styles/project-detail.css | 6 +- src/app/styles/settings-page.css | 2 +- src/app/styles/tokens.css | 18 +- src/app/styles/workspace.css | 2 +- src/components/ui/app-dialog.tsx | 13 +- src/components/ui/bottom-sheet.tsx | 6 +- src/components/ui/checkbox.tsx | 2 +- tailwind.config.ts | 9 +- 22 files changed, 102 insertions(+), 876 deletions(-) diff --git a/src/app/styles/cert-detail.css b/src/app/styles/cert-detail.css index e64fffc8..0ca5bc6e 100644 --- a/src/app/styles/cert-detail.css +++ b/src/app/styles/cert-detail.css @@ -776,10 +776,6 @@ } } -/* "more" link below the short description — styling now lives in - `.more-link-row` / `.more-link` in `components.css` so the - project detail and cert detail share the same affordance shape. */ - /* ---------- Headline byline ---------- Sits directly under the title: @@ -2016,7 +2012,7 @@ .create-cert__contrib-id-input--invalid:focus { border-color: var(--color-error); - box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15); + box-shadow: 0 0 0 2px var(--color-error-ring); } /* Per-row identity error — slots into the contributor grid by @@ -2051,7 +2047,7 @@ top: 100%; left: 0; right: 0; - z-index: 60; + z-index: var(--z-popover); max-height: 220px; overflow-y: auto; background: var(--bg-elevated); @@ -2501,7 +2497,10 @@ position: absolute; top: 8px; right: 8px; - z-index: 500; + /* The sibling .certs-map is an isolated stacking context, so any + positive z paints this button above the whole map — no need to + outbid Leaflet's internal 200-700 pane values. */ + z-index: var(--z-local-raise); width: 30px; height: 30px; display: inline-flex; diff --git a/src/app/styles/components.css b/src/app/styles/components.css index 30d9726f..ec343aad 100644 --- a/src/app/styles/components.css +++ b/src/app/styles/components.css @@ -16,39 +16,6 @@ align-items: center; } -/* ========== "More" link ========== - - Shared affordance used wherever a truncated / summarised piece - of content links to its full view (project / cert short - description → long description tab, etc.). Right-aligned to the - content edge so it sits at the predictable "continue reading" - spot. Quiet underlined text — never a button. */ -.more-link-row { - /* Sits flush against the preceding paragraph (no top margin) so - "more" is the immediate next line under the truncated content. - Bottom margin creates the breathing room before whatever - section follows. */ - margin: 0 0 16px; - font-size: 0.875rem; - text-align: right; -} - -.more-link { - color: var(--fg-primary); - text-decoration: underline; - text-underline-offset: 2px; -} - -.more-link:hover { - text-decoration-thickness: 2px; -} - -.more-link:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; - border-radius: var(--radius); -} - /* ========== Micro-interactions ========== */ /* Press feedback — applied to interactive cards and buttons via utility class */ @@ -76,7 +43,7 @@ } .delete-record-dialog__warning strong { - color: var(--color-error, #b91c1c); + color: var(--color-error); } .delete-record-dialog__field { @@ -123,14 +90,14 @@ .delete-record-dialog__input:focus { outline: none; - border-color: var(--color-error, #b91c1c); - box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15); + border-color: var(--color-error); + box-shadow: 0 0 0 2px var(--color-error-ring); } .delete-record-dialog__error { margin: 0 0 12px 0; font-size: 0.8125rem; - color: var(--color-error, #b91c1c); + color: var(--color-error); } .delete-record-dialog__actions { @@ -176,7 +143,7 @@ overflow-y: auto; background: var(--bg-elevated); border-radius: var(--radius); - box-shadow: 0 24px 64px var(--navy-overlay-30); + box-shadow: var(--shadow-modal); border: 1px solid var(--border-default); animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); } @@ -396,48 +363,11 @@ border-color: var(--fg-muted); } -/* Mobile bottom sheet for feedback (reuses .bottom-sheet base classes) */ -.feedback-bottom-sheet__backdrop { - display: none; -} - -.feedback-bottom-sheet { - display: none; -} - @media (max-width: 799px) { .feedback-modal__backdrop--desktop { display: none; } - .feedback-bottom-sheet__backdrop { - display: block; - z-index: var(--z-feedback); - } - - .feedback-bottom-sheet { - display: flex; - z-index: var(--z-feedback-above); - } - - .feedback-bottom-sheet .feedback-modal__textarea { - resize: none; - min-height: 80px; - font-size: 16px; - } - - .feedback-bottom-sheet.bottom-sheet--expanded .feedback-modal__textarea { - min-height: 120px; - } - - .feedback-bottom-sheet .feedback-modal__input { - font-size: 16px; - } - - .feedback-bottom-sheet .feedback-modal__body { - padding: 16px 20px; - } - .feedback-modal__expand { display: none; } @@ -505,30 +435,6 @@ gap: 8px; } -.handle-edit__row { - display: flex; - align-items: flex-start; - gap: 0; -} - -.handle-edit__row > div:first-child { - flex: 1; - min-width: 0; -} - -.handle-edit__suffix { - font-size: 0.875rem; - font-family: monospace; - color: var(--fg-muted); - padding: 0 0 0 2px; - white-space: nowrap; - /* Align with the input field: label height (~20px + 6px margin) + input padding */ - margin-top: 26px; - height: 44px; - display: flex; - align-items: center; -} - .username-card__switch-btns { display: flex; flex-wrap: wrap; @@ -703,11 +609,6 @@ margin-bottom: 8px; } -.email-section--form { - display: flex; - flex-direction: column; -} - .email-section__status--success { color: var(--color-success-text); margin-top: 4px; @@ -721,11 +622,6 @@ margin-bottom: 0; } -.email-section--form .email-section__hint { - margin-top: 0; - margin-bottom: 12px; -} - .email-section__fields { display: flex; flex-direction: column; @@ -745,18 +641,6 @@ } /* ========== Custom Domain Modal ========== */ -.domain-modal__backdrop { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - background: var(--navy-overlay-70); - backdrop-filter: blur(8px); - animation: modalFadeIn 200ms ease-out; -} - .domain-modal { width: 90vw; max-width: 480px; @@ -765,7 +649,7 @@ overflow: hidden; display: flex; flex-direction: column; - box-shadow: 0 24px 64px var(--navy-overlay-30); + box-shadow: var(--shadow-modal); border: 1px solid var(--border-default); animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); } @@ -1147,7 +1031,6 @@ } @media (prefers-reduced-motion: reduce) { - .domain-modal__backdrop { animation: none; } .domain-modal { animation: none; } .domain-modal__spinner { animation: none; } } @@ -1320,103 +1203,6 @@ color: var(--color-error); } -/* ========== Connected Apps (/apps directory) ========== */ -.connected-apps__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 4px; -} - -.connected-apps__count { - font-size: 0.75rem; - font-weight: 500; - color: var(--fg-muted); - background: var(--bg-sunken); - padding: 4px 10px; - border-radius: var(--radius); -} - -.connected-apps__list { - list-style: none; - margin: 12px 0 0; - padding: 0; - display: flex; - flex-direction: column; -} - -.connected-apps__item-wrap { - display: contents; -} - -.connected-apps__item { - display: flex; - align-items: center; - gap: 14px; - padding: 16px; - text-decoration: none; - color: inherit; - border-radius: var(--radius); - transition: background var(--transition-fast); -} - -.connected-apps__item + .connected-apps__item { - border-top: 1px solid var(--border-subtle); -} - -.connected-apps__item--link:hover { - background: var(--overlay-weak); -} - -.connected-apps__item:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: -2px; -} - -.connected-apps__icon { - width: 40px; - height: 40px; - border-radius: var(--radius); - background: var(--bg-sunken); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - overflow: hidden; -} - -.connected-apps__logo { - width: 100%; - height: 100%; - object-fit: cover; -} - -.connected-apps__info { - flex: 1; - min-width: 0; -} - -.connected-apps__name { - font-size: 0.9375rem; - font-weight: 600; - color: var(--fg-primary); - margin: 0; -} - -.connected-apps__desc { - font-size: 0.8125rem; - line-height: 1.5; - color: var(--fg-muted); - margin: 2px 0 0; -} - -/* Tighter rows on small screens. Drop the description to keep each - row compact — the user can tap through for more. */ -/* (Removed a non-canonical `@media (max-width: 520px)` block that tightened - `.connected-apps__item` and hid `.connected-apps__desc`; the ≤799px block - below already does both across the whole mobile range, so it was redundant.) */ - /* ========== Responsive: Dashboard & Pages (≤799px / mobile) ========== */ @media (max-width: 799px) { .app-shell__content { @@ -1464,65 +1250,6 @@ font-size: 1.375rem; } - /* Personal info: single column */ - .personal-info__grid { - grid-template-columns: 1fr; - } - - .personal-info__field { - padding: 10px 0; - font-size: 0.8125rem; - } - - .personal-info__field--mono { - font-size: 0.75rem; - } - - /* Connected apps items: hide status text on very small screens */ - .connected-apps__item { - gap: 10px; - padding: 12px 0; - } - - .connected-apps__desc { - display: none; - } - - /* App detail cards (connected apps page): stack */ - .app-detail { - flex-direction: column; - gap: 12px; - } - - .app-detail__icon { - width: 48px; - height: 48px; - } - - .app-detail__header { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - - /* My data records: stack meta below main */ - .my-data__record { - flex-direction: column; - gap: 8px; - } - - .my-data__record-meta { - flex-direction: row; - align-items: center; - gap: 12px; - } - - .my-data__did-info { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - /* Edit profile: actions stack full-width on mobile (covers both