Skip to content

Phase 2 endorsement response flow + accumulated staging work#61

Open
hb-agent wants to merge 155 commits into
mainfrom
staging
Open

Phase 2 endorsement response flow + accumulated staging work#61
hb-agent wants to merge 155 commits into
mainfrom
staging

Conversation

@hb-agent
Copy link
Copy Markdown
Collaborator

Headline feature: Phase 2 endorsement response flow

The recipient now has an explicit lever to hide unwanted endorsements via app.certified.badge.response. Closes the structural gap left by Phase 1: anyone could endorse anyone, but the recipient had no curation surface. Default-show stays the default (low friction); an opt-out (Hide) writes a rejected response on the recipient's own PDS and filters the award out of profile read paths.

What ships

  • Show / Hide buttons on /notifications rows where the underlying record is a badge.award. Loud, inline.
  • Quiet kebab menu on own-profile "Received" rows and on /endorsements Received tab. State indicator inside the menu ("Showing on your profile" / "Hidden from your profile" / etc.) plus actions for the current state.
  • Reset to default sweeps every response record on the award, returning to un-responded state.
  • Nav-badge pending counter on the Endorsements nav item (desktop rail + mobile sidebar) — the discovery cue that closes the default-show gap until the notifications backend learns about badge.award.
  • Undo toast after Hide (6 s, aria-live="polite", single-click revert).
  • Focus management after Hide moves to the next row's kebab (WAI-ARIA AC#7).
  • Roving tabindex inside the kebab menu (Arrow keys, Home/End, Esc).
  • Owner-only response state — non-owner viewers never receive per-row response data past the hook boundary. The visible-filter operates on the data without exposing it.
  • Mobile touch targets ≥44px on both controls.

Architecture decisions

Captured in writing per deep-flow §"Decisions belong in writing":

  • docs/badge-response-flow/plan.md — alternatives considered for default visibility (opt-out chosen), action-surface placement (loud notifications + quiet kebab), and the write strategy (append-only with rkey lexicographic tie-break for createdAt collisions).
  • docs/badge-response-flow/review-round-1.md — three parallel reviewer agents on the plan, each finding consolidated as accept/reject with rationale. Items folded back into the plan in place.
  • docs/badge-response-flow/review-round-1-impl.md — three parallel reviewer agents on the implementation, six critical items addressed in a follow-up commit (cross-hook staleness via useSyncExternalStore, focus + undo toast, roving tabindex, aria-label disambiguation, mobile touch targets, owner-only state indicator).

Accumulated non-feature work

Same PR, separate set of commits:

  • docs(agents) — adds the deep-flow process spec to AGENTS.md §26.
  • feat(endorsements) — Phase 1 migration onto app.certified.badge.{definition,award} from the legacy app.certified.temp.graph.endorsement lexicon (each user owns their endorsement badge).
  • fix(cache), fix(profile), fix(edit-profile), fix(left-rail), fix(news), fix(about), fix(legal), fix(apps), feat(news), refactor(groups) — accumulated polish since main was last cut. Per-commit messages describe scope.

Breaking changes

None at the contract level. The endorsement lexicon migration (Phase 1) deprecates app.certified.temp.graph.endorsement for new writes but the proxy allowlist still accepts the legacy collection so the feed-side "trusted evaluator" filter keeps working. A separate follow-up will migrate the feed filter onto badges.

Out of scope (filed as follow-ups)

  • badge.award rate-limiting on the xrpc proxy — abuse-mitigation, deferred per plan §"Out of scope" D1. Track as a separate security PR.
  • Notification-service awareness of badge.award — backend extension that lets app.certified.badge.award firehose events emit "endorsement" notifications. Today only legacy temp lexicon notifications surface. The nav-badge counter mitigates the discovery gap; the proper fix lives in the notifications backend (separate issue against the indexer team).
  • Indexer-side subject filter + nullable badge joinhb-agent/magic-indexer#65. When that lands, the PDS fan-out scan in useReceivedEndorsements collapses to a single GraphQL query.
  • Magic-indexer testdata lexicon drifthb-agent/magic-indexer#67 (PR landed for app/certified/*) and hb-agent/magic-indexer#68 (heads-up for org/hypercerts/*).

Verification

  • npx tsc --noEmit — 0 errors
  • npx eslint src/ — 0 errors, 28 warnings (baseline 27; +1 from a latest-ref-in-useEffect pattern in use-profile-responses; within plan AC#10 ±2 budget)
  • npx next build — green
  • Reviewer: manual smoke test of the response flow:
    • Sign in as account A; endorse account B from /endorsements "+ New"
    • Sign in as account B; open /endorsements Received tab — see A's endorsement
    • Click kebab → Hide from profile. Row disappears immediately. Toast appears with Undo.
    • Click Undo within 6 s. Row reappears.
    • Click kebab → Hide. Wait > 6 s. Toast disappears.
    • Re-open kebab → Reset to default. Row stays visible; vestigial responses cleared.
    • On account A's session, visit B's profile. Endorsement renders if visible to B; gone if B hid it.
    • On an anonymous tab, visit B's profile. Endorsement visibility matches B's curation.
    • Open the desktop nav — Endorsements item shows a small chip when there are pending awards.
    • Keyboard: Tab to kebab, Enter to open, ArrowDown to move, Esc to close (focus restored to trigger).

🤖 Generated with Claude Code

holkexyz and others added 28 commits May 12, 2026 11:39
Replace the certified-app working tree with the current state of
certs-social/staging, which has diverged ahead in social/feed/search/
notifications/endorsements/activity surfaces (and the apps directory
just landed there).

Source: hypercerts-org/certs-social@af81cf8 (staging at sync time).

What's preserved from certified-app:
- Legal pages (imprint, privacy, terms, dsa) — these had newer
  compliance updates not yet ported to certs-social: DDG Impressum,
  Vercel Analytics processing basis + Art. 13(2)(d) complaint right,
  Director update.
- next.config.ts redirects (settings/security, settings/account) and
  a new permanent redirect /connected-apps → /apps so any inbound
  links to the old route still land.
- Project metadata (AGENTS.md, README.md, .env.local.example) — these
  reflect certified-app's project-specific guidance and target domain.
- package.json `name: "certified-app"`.

What was rebranded inline (certs.social → certified.app):
- src/app/{layout,robots,sitemap}.tsx — metadataBase, sitemap URL,
  JSON-LD WebSite/Organization URLs.
- src/app/{about,feed,search}/* canonical URLs.
- src/app/about/page.tsx body copy ("on certs.social or any partner
  application" → "on certified.app or any partner application").
- next.config.ts images.remotePatterns hostname (**.certs.social →
  **.certified.app).
- src/components/dashboard/username-card.tsx — legacy fallback now
  matches both .certified.app and .certs.social so users migrating
  accounts retain the "you can manage this handle" experience.
- Comments in src/config/trusted-evaluators.ts and src/lib/atproto/
  indexer.ts.

What this changes for users (heads-up):
- OAuth `client_id` is now `https://certified.app/.well-known/
  oauth-client-metadata` (different from the certs-social client_id).
  Existing certified-app users will re-auth on first visit; this is
  expected behavior when the deployment's PUBLIC_URL changes.
- Vercel env vars on the certified-app project keep working — code
  reads them by name (COOKIE_SECRET, UPSTASH_REDIS_*, PUBLIC_URL,
  RESEND_*). Verify they're set; INDEXER_URL/INDEXER_DID may need to
  be added if not already there.

tsc 0 errors, eslint 0 errors / 27 warnings, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sync from certs-social/staging (replaces certified-app working tree)
Switch defaultTheme from "system" to "light" in the next-themes
provider. The toggle still offers system + dark, and a user's saved
preference still wins on return visits — only first-paint changes:
dark-OS visitors now land on light instead of dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the "Suggested to endorse" and "Groups to join" sections (both
were empty-state stubs awaiting real endpoints) and replace with a
News feed pulling public posts from @certified.app via the
unauthenticated Bluesky appView (public.api.bsky.app/xrpc).

UX: latest post visible by default. "More" button pages three older
posts at a time and hides itself once the timeline is exhausted
(upstream stops returning a cursor, or returns a partial page).
Each post deep-links to bsky.app/profile/certified.app/post/<rkey>
in a new tab so users can like/reply natively.

Implementation:
- src/hooks/use-bsky-posts.ts — singleflight + cancellation-safe
  cursor pagination; filters to posts_no_replies for the "news" feel;
  validates the upstream shape before trusting fields.
- src/components/right-rail/news-section.tsx — UI, reuses the
  existing formatRelativeTime util.
- CSS lives in styles/layout.css alongside the rest of the right-rail
  rules; reuses --overlay-weak / --border-subtle tokens for hover and
  separators. Six-line clamp on post text so one long post doesn't
  dominate the rail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was left-aligned with font-weight 500, which read as the "primary
action" of the rail next to plain paragraph text. Now centered with
the default 400 weight so it reads as a quiet pagination control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace "Your identity and data — everywhere you go." with
"One identity. Your data. Every app in the network."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recent sync pulled in certs-social's brandmark for /icon.png and
/apple-icon.png. Replace with the certified brandmark (C + checkmark)
from main, since that's the favicon users associate with the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the impact-contributors framing (inherited via the
certs-social sync) with main's passwordless-identity narrative —
the one already shipping on certified.app. Sections: What is
Certified, How does it work, What is AT Protocol, Who operates
Certified, Sign-in-with-Google comparison, Open source,
Infrastructure, Contact.

Styling adapted to staging's design system: font-headline (not
font-mono), text-[var(--fg-primary)] (no text-navy token here),
PageTitle component (not inline <h1>), existing
certs-hero-1200x630.png OG image. Content matches main verbatim
otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier order put the operator section third (before the
Sign-in-with-Google comparison and Open source). It reads better as
"what is it, how does it work, how is it different, transparency
notes, then who runs it" — operator credit lands closer to Contact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the placeholder long-descriptions for all five apps with the
canonical one-line descriptions provided by the team. Short tagline
(`desc`) untouched — it's not currently rendered anywhere, but kept
in case it surfaces later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the 6-line -webkit-line-clamp on .news__text — posts are now
rendered in full and the rail grows to fit. white-space: pre-wrap
still preserves intentional line breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plain-text rendering meant URLs in @certified.app posts showed up as
unclickable text and @-mentions as bare strings. New RichText
component reads the post's `facets` array and turns each byte-indexed
span into an inline <a>:
- #link  → opens the URL in a new tab (sanitised through safeHttpUrl
  so a malicious facet can't ship javascript: into an href)
- #mention → opens bsky.app/profile/<did>
- #tag → opens bsky.app/hashtag/<tag>

UTF-8 byte slicing: facet indices are UTF-8 byte offsets, not JS
character indices. We TextEncoder→slice→TextDecoder so posts that
contain emoji, accents, or CJK don't get corrupted spans.

Card structure: the whole card used to be wrapped in one outer <a>
pointing at the bsky.app permalink, which made nested facet anchors
invalid HTML. Restructured so the article body is plain, and the
relative-time element is the explicit permalink (Twitter-style).
Inner anchors stopPropagation so clicks don't bubble.

Defensive parsing in normalizeFacets() drops malformed entries
(non-numeric byteStart/byteEnd, byteEnd <= byteStart, no recognised
features) before they reach React.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.left-rail__signin-card was display:none below 1300px, so the entire
sign-in block (button included) disappeared from the icon-only rail
layout (800-1299). Unauth visitors had no in-rail call to action.

Now the card stays display:flex at every width — title + body collapse
out below 1300, the chrome (padding/border/background) drops away too,
and the 44×44 button stays in the bottom slot mirroring the
account-switcher trigger on the authed path. Added a LogIn glyph so
the icon-only state is recognisable, plus an aria-label since the
visible "Sign in" text is hidden at narrow widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bsky.app routes on the raw `did:plc:xxx` form — the colons are
structural separators, not encodable path characters. Running the
DID through encodeURIComponent turned them into %3A and produced
404s like /profile/did%3Aplc%3Axqrmqd4h7f3fpe7ue7qdhp7h.

Drop the encoding and gate on isValidDid() first so a malformed
facet can't slip an arbitrary string into the href — the regex
accepts did:plc:<24 base32> and did:web:<domain charset> only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking the Certified logo used to always go to "/" — fine for a
personal-acting visitor (the homepage redirects to their profile),
but for a group-acting session "/" stayed at the bare home page and
gave no way to jump back to the group's profile from elsewhere in
the app.

Brand href is now computed per-session:
- Unauth: "/"
- Personal: /profile/<handle>
- Group: /groups/<groupDid>  (via resolvePostSwitchPath)

Uses the raw `handle` from useSession rather than `identity.handle`
for the personal path, because `identity.handle` is the *group*
handle when activeOrg is set and would otherwise mis-route to
/profile/<group-handle>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The org-profile page was built before /profile/[handle] generalised to
group actors. The two routes now overlap on the public profile view —
same data, different chrome. Consolidate to one.

What lands:
- /groups/[groupDid] is now a server component that redirects to
  /profile/<handle>. resolveHandle() on the server gives us the handle
  for the group's DID; falls back to /groups (the list) if PLC hasn't
  propagated yet. Existing bookmarks + account-switcher links keep
  working through the redirect.
- /profile/[handle] page now computes admin context: when the viewed
  profile is a group the viewer has owner/admin role in, surface an
  Edit Profile button (linking /groups/<did>/edit-profile) and a
  Settings cog (linking /groups/<did>/settings) on the hero. Both
  admin sub-routes remain — they're the actual admin entry points.
- "Acting as this group" eyebrow appears on the profile hero when
  activeOrg.groupDid matches the viewed DID; "Your profile" eyebrow
  stays for personal own-profile.
- resolvePostSwitchPath prefers /profile/<handle>. Account-switcher,
  brand link, and any future caller inherit the new destination
  automatically. Falls back to /groups/<did> (which redirects) if a
  Group ever lacks a handle.

ProfileHeader API change: dropped `isOwnProfile`, added `editHref`,
`settingsHref`, `eyebrow`. The page passes whichever ones apply.
hasAdminActions = editHref || settingsHref controls the Edit/Settings
vs. Follow branch. ProfileGroups still receives `isOwnProfile` from
the page for its role-visibility check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eyebrow on the profile hero is now "Your profile" for own-profile
and unset for everything else (including group profiles where the
viewer is acting as that group). The Edit + Settings buttons already
signal the admin context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both edit-profile flows used router.push() after save, which kept
the in-app caches (profile context with avatar URL, navbar avatar,
useUserProfile on the destination, blob URLs) alive across the
navigation. Result: the freshly-saved record was on the PDS but the
UI kept showing stale data for tens of seconds until each cache
decided to refetch.

Switch to window.location.assign() — full page reload so every
component remounts with fresh data from the server. Trade-off is a
brief blank flash, but it's the only reliable way to invalidate every
cache layer at once without plumbing manual invalidation through 4-5
context providers.

Bonus while in there: group edit-profile's Cancel button now returns
to the group profile (via /groups/<did>, which server-redirects to
/profile/<handle>) instead of bouncing the user to the homepage.
Same for the post-save destination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stacked caches were causing "I saved but my old name still shows
after reload" reports:

1) /api/resolve-did returned `Cache-Control: public, max-age=60,
   stale-while-revalidate=300` for everyone. A reload after a save
   served the cached response for up to 60s — full minute of "did my
   edit even take?". And SWR meant another 5 minutes of stale-served
   responses with background refetch.
2) Same-session xrpc reads (own getRecord, listRecords, getSession)
   had no explicit Cache-Control, so the browser was free to apply
   heuristic caching for the same URLs.

Fixes:

- resolve-did now returns `private, no-store` when the request is
  for the authenticated user's OWN DID. Foreign profile lookups —
  the common case where caching actually pays off (feed bylines,
  contributor rows, handle search) — keep the public 60s+SWR cache.
- Same-session xrpc getRecord/listRecords/getSession explicitly set
  `private, no-store` on the response. Defense-in-depth so the edit
  form sees the freshly-saved record on its next mount.
- Both edit-profile handlers (personal + group) call
  `/api/resolve-did?did=...` with `cache: "reload"` after putProfile,
  before the hard navigation. Evicts any pre-existing browser-cached
  entry sitting in disk cache from before the headers above shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These pages preserved main's heading styles during the sync from
certs-social and ended up using font-mono + text-navy — a monospace
heading font and a Tailwind color that doesn't exist in staging's
config (silently falls back to default). About/headings on every
other staging page use font-headline (Noto Serif) and
text-[var(--fg-primary)], so legal pages looked off.

Mechanical swap:
  font-mono text-{h1,xl,lg} text-navy
  → font-headline text-{h1,xl,lg} text-[var(--fg-primary)]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous pass swung too far — `private, no-store` on resolve-did for
the authed user's own DID and on every same-session xrpc read meant
clicking your own profile in the nav always paid a full network
round-trip, with a visible loading flash for ~200ms.

Tune per-endpoint:
- /api/resolve-did (own DID): private, max-age=10 (was no-store).
  Re-navigation within 10s comes from disk cache; edits after a
  save still propagate immediately because the save handler hits
  this URL with `cache: "reload"` to evict the entry.
- xrpc same-session listRecords: private, max-age=5. Powers the
  Activities tab on the own profile; 5s is short enough that fresh
  posts surface quickly, long enough that the loading flash is
  imperceptible on quick re-nav.
- xrpc same-session getRecord: stays no-store. The edit form is the
  primary consumer and any stale window risks "saved but the form
  shows the previous value" — that bug was the whole reason the
  no-store headers landed in the first place.
- xrpc same-session getSession: stays no-store. Session state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hard cutover from the temp `app.certified.temp.graph.endorsement`
lexicon to the stable Certified-badge lexicons. Every user defines
their own endorsement (no centralized issuer); the trusted-evaluator
allowlist is removed from the profile-level Endorsements UI.

Data model
- `app.certified.badge.definition` — lazy-created on first endorse.
  badgeType="endorsement", title="Endorsement", icon intentionally
  omitted (UI renders the live issuer avatar). Lexicon currently
  marks icon required; a PR to make it optional is tracked separately.
- `app.certified.badge.award` — the endorsement itself, written to
  the issuer's own PDS. Subject = recipient DID (bare string per
  app.certified.defs#did). Optional note (up to 1000 chars) — new
  field surfaced in the modal.
- `app.certified.badge.response` — phase 2 (accept/reject). Lexicon
  + allowlist entry land in this PR so we don't need a second route
  change to enable it later.

New code
- src/lib/atproto/badges.ts — types, CRUD, ensureEndorsementDefinition
  (singleflighted so a double-click can't create two definitions),
  helpers for subject parsing.
- src/hooks/use-endorsements.ts — `useGivenEndorsements` now reads
  awards + definitions from the target user's PDS and filters to
  badgeType=endorsement. No indexer dependency for this view.
- src/hooks/use-received-endorsements.ts — `useReceivedEndorsements`
  runs a network-wide scan: enumerate every known certified user via
  the indexer's appCertifiedActorProfile, fan-out PDS listRecords
  for their awards, filter to those targeting the profile DID,
  resolve each issuer's definitions only for candidates, keep
  endorsement-typed ones. Module-level cache, 5min TTL, focus
  revalidate. This is a workaround until the magic-indexer adds a
  subjectDid filter on AppCertifiedBadgeAward (tracked at
  hb-agent/magic-indexer#65).

UI
- NewEndorsementModal: optional Note textarea (max 1000 chars).
  Write path now ensureEndorsementDefinition + createEndorsementAward.
- /endorsements page: "Received" tab renders all award issuers (no
  trusted-evaluator gating); "Given" tab unchanged shape; revoke
  uses deleteEndorsementAward.
- profile/[handle] Endorsements tab: matched.
- EndorsementRow: new optional `note` prop shown under handle.

Out of scope (intentional)
- Activity feed's "trusted evaluator" filter still reads the legacy
  temp.graph.endorsement collection via useTrustedEndorsedDids. That
  surface answers a different question ("which authors are surfaced
  in the For-you feed") and migrates in a follow-up.
- The xrpc allowlist keeps the legacy collection entry so the feed
  filter keeps working until we move that too.

Verification
- tsc clean, eslint 0 errors (27 warnings — pre-existing baseline),
  next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canonical lexicon at hypercerts-org/hypercerts-lexicon lists icon as
optional (required = title, badgeType, createdAt). My earlier comment
guarded against a required-icon constraint based on a stale copy in
magic-indexer/testdata — there's nothing to guard against. Clean up
the comments accordingly. No behavioral change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the operator's standard process for non-trivial work:
plan-and-review, multiple parallel reviewer agents per round,
explicit alternatives + rationale in writing, commit directly to
staging (project-specific override of the global feature-branch
default), Draft PR staging->main, operator merges.

Adds a footnote translating the doc's illustrative Go commands to
the Next.js gate this repo actually uses (tsc / eslint / next build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per AGENTS.md §26 deep-flow. Plan covers the recipient accept/
reject lever via app.certified.badge.response, with alternatives
considered for default visibility (chose opt-out), action surface
(loud-on-notifications + quiet-kebab-on-profile), and write
strategy (append-only, latest wins, rkey tie-break). Review-round-1
captures three reviewers' findings across ATProto/lexicon, UX/
a11y, and perf/security lenses — every block-level item accepted
and folded back into the plan in place; rate-limiting and
notification-service awareness deferred to follow-up issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the lexicon-side plumbing for app.certified.badge.response and
two response-control components, plus the hook layer that wires
profile-owner-side responses into the existing
useReceivedEndorsements scan. No UI surface uses these yet; that
lands in the next commit.

Key contracts from the plan + round-1 review:

- Response join is a SINGLE listRecords against the profile owner's
  PDS, never per-issuer. Marginal cost on top of the existing scan:
  +1 round-trip, not +386. useReceivedEndorsements bakes this in.
- Owner-only response state. useOwnResponseStates exposes the per-
  award state map; non-owner read paths never receive it (privacy:
  prevents the "vouched harder for that one" misread).
- Unknown response values (knownValues enum is extensible) treated
  as "default" — never silently hide based on a value we don't
  understand.
- Append-only writes with rkey lexicographic tie-break for equal
  createdAt collisions. Cross-device clock skew documented as a
  known limitation.
- StrongRef joins on URI only, not CID, so a re-created award
  isn't a dangling foreign key.

Files:
- src/lib/atproto/badges.ts — listResponses / createResponse /
  deleteResponse / deleteAllResponsesForAward, plus
  resolveResponseState helper. BadgeResponseValue.response widened
  from union to plain string per the knownValues extensibility.
- src/hooks/use-profile-responses.ts — fetch a specific DID's
  responses with module-cache + focus revalidate.
- src/hooks/use-own-response-states.ts — viewer-only wrapper that
  exposes the resolve(awardUri) → state lookup.
- src/hooks/use-pending-awards-count.ts — count of un-responded
  awards on the viewer's profile; powers the nav chip.
- src/hooks/use-received-endorsements.ts — now joins the profile-
  owner's responses and filters out latest=rejected; returns the
  visible awards only (no per-row state).
- src/components/badges/response-buttons.tsx — loud Show/Hide for
  /notifications rows. aria-pressed toggle-group pattern.
- src/components/badges/response-menu.tsx — quiet kebab with
  "Hide from profile" / "Show on profile" / "Reset to default".
  WAI-ARIA menu pattern, roving tabindex via Esc + focus return.
- src/app/styles/feed.css — styles for both controls + the
  endorsement-row__note that wasn't yet in CSS, plus the nav-chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d /endorsements

Surfaces the response controls built in the previous commit.

Notifications row: when the underlying record is a badge.award
(detected via the URI), render the loud Show/Hide buttons inline
after the body. When the notification is legacy temp-lexicon
endorsement (today's reality until the notifications backend
detects badge.awards), the row stays read-only as before. Wrapping
becomes a div instead of Link when buttons are present — nested
<button> inside <a> is invalid HTML.

Profile Endorsements tab: own-profile rows in "Endorsements
received" now carry the quiet kebab. Non-owner views get no per-
row state at all (privacy: prevents the "vouched harder for that
one" misread). useAuth-derived `viewerIsOwner` gates the menu.

/endorsements page: the Received tab is always the viewer's own
inbox, so the kebab always shows on those rows.

Nav-badge counter: a small chip on the Endorsements nav item
showing the count of un-responded awards targeting the viewer.
Closes the discovery gap from default-show — a recipient who
never visits /notifications will still see the chip on next page
load and route in. Reuses the same module caches as the scan and
response fetches, so no extra network cost.

ReceivedEndorsement gains `cid` so the response strongRef can be
built from the row data.

Verified locally: tsc 0 errors; eslint 0 errors / 27 warnings
(baseline preserved); next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, a11y)

Picks up the six critical items from
docs/badge-response-flow/review-round-1-impl.md plus two of the
important ones.

CB1 — cross-hook staleness via useSyncExternalStore.
use-profile-responses.ts refactored to a module-level external
store. Both useReceivedEndorsements and useOwnResponseStates now
subscribe; a Hide write invalidates + refetches and every
subscribing hook instance re-renders together. Previously the two
hooks each kept their own useState copy of responses, so the row
the user just rejected stayed visible on /endorsements until
something else remounted.

CB2 — AC#7 + AC#8 focus + undo toast. ResponseMenu now captures
the next sibling kebab trigger before the write, restores focus to
it after the row is removed (or to the trigger if the row stays),
and surfaces a 6-second aria-live="polite" toast with an Undo
button that sweeps every response on the award (returning to
default).

CB3 — roving tabindex inside the kebab menu. Auto-focus the first
menuitem on open; ArrowUp/ArrowDown moves between items;
Home/End jump. Esc still closes + returns focus to trigger.
Matches WAI-ARIA menu pattern.

CB4 — aria-label collision on desktop nav rail. NavItem grows an
optional `badgeUnit` so different surfaces can carry different
units. Notifications stays "unread"; Endorsements is "pending."

CB5 — touch-target sizes. Mobile-only override in feed.css bumps
.response-menu__trigger to 44x44, .response-buttons__btn to
44px min-height, and menu items to 44px min-height. Desktop UI
unchanged.

CB6 — owner-only state indicator inside the kebab. Per-row
"Showing on your profile" / "Hidden from your profile" /
"Showing on your profile (default)" / "Unrecognised response
state" label above the action items, owner-only by surface gating.

IB1 — nav-rail no longer kicks off cold fan-out scans.
usePendingAwardsCount becomes a passive reader: peekCachedReceived-
Endorsements + useProfileResponses (cheap single listRecords). The
chip stays hidden when the scan cache is cold; populates when the
user actually visits /endorsements or their own profile, which is
where the fan-out belongs.

IB3 — plan doc reworded ("over the wire" overclaim).

IB4 — dead .left-rail__pending-chip CSS removed (it was unused;
nav-rail renders .left-rail__badge).

Verified:
- tsc 0 errors
- eslint 0 errors / 28 warnings (baseline 27; +1 from the new
  latest-ref-in-useEffect pattern in use-profile-responses, within
  the plan AC#10 ±2 budget)
- next build green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
certified-app Ready Ready Preview, Comment May 18, 2026 8:22am
certified-app (staging) Ready Ready Preview, Comment May 18, 2026 8:22am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Too many files!

This PR contains 269 files, which is 119 over the limit of 150.

To get a review, narrow the scope:
• coderabbit review --type committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5497c27-38fa-472f-a4f7-08e8b5b0017e

📥 Commits

Reviewing files that changed from the base of the PR and between 0728b53 and e09f777.

⛔ Files ignored due to path filters (31)
  • package-lock.json is excluded by !**/package-lock.json
  • public/assets/certified_brandmark_black.png is excluded by !**/*.png
  • public/assets/certified_brandmark_black.svg is excluded by !**/*.svg
  • public/assets/certified_brandmark_black_192.png is excluded by !**/*.png
  • public/assets/certified_brandmark_black_512.png is excluded by !**/*.png
  • public/assets/certified_poweredby_horizontal_black.png is excluded by !**/*.png
  • public/assets/certified_poweredby_horizontal_black.svg is excluded by !**/*.svg
  • public/assets/certified_poweredby_left_black.png is excluded by !**/*.png
  • public/assets/certified_poweredby_left_black.svg is excluded by !**/*.svg
  • public/assets/certified_poweredby_right_black.png is excluded by !**/*.png
  • public/assets/certified_poweredby_right_black.svg is excluded by !**/*.svg
  • public/assets/certified_signin_black.png is excluded by !**/*.png
  • public/assets/certified_signin_black.svg is excluded by !**/*.svg
  • public/assets/certified_signinwith_black.png is excluded by !**/*.png
  • public/assets/certified_signinwith_black.svg is excluded by !**/*.svg
  • public/assets/certified_wordmark_black.png is excluded by !**/*.png
  • public/assets/certified_wordmark_black.svg is excluded by !**/*.svg
  • public/assets/certs-hero-1200x630.png is excluded by !**/*.png
  • public/assets/certs_brandmark_black.png is excluded by !**/*.png
  • public/assets/certs_brandmark_white.png is excluded by !**/*.png
  • public/assets/icon-192.png is excluded by !**/*.png
  • public/assets/icon-512.png is excluded by !**/*.png
  • public/assets/partners/silvi_logo.png is excluded by !**/*.png
  • public/assets/powered_by_certified_black.svg is excluded by !**/*.svg
  • public/brand/poweredby/certified_poweredby_horizontal_black.svg is excluded by !**/*.svg
  • public/brand/poweredby/certified_poweredby_horizontal_black_18h.png is excluded by !**/*.png
  • public/brand/poweredby/certified_poweredby_left_black.svg is excluded by !**/*.svg
  • public/brand/poweredby/certified_poweredby_left_black_32h.png is excluded by !**/*.png
  • public/brand/poweredby/certified_poweredby_right_black.svg is excluded by !**/*.svg
  • public/brand/poweredby/certified_poweredby_right_black_32h.png is excluded by !**/*.png
  • public/brand/signin/certified_signinwith_black_40h.png is excluded by !**/*.png
📒 Files selected for processing (269)
  • .agents/skills/impeccable/SKILL.md
  • .agents/skills/impeccable/reference/adapt.md
  • .agents/skills/impeccable/reference/animate.md
  • .agents/skills/impeccable/reference/audit.md
  • .agents/skills/impeccable/reference/bolder.md
  • .agents/skills/impeccable/reference/brand.md
  • .agents/skills/impeccable/reference/clarify.md
  • .agents/skills/impeccable/reference/cognitive-load.md
  • .agents/skills/impeccable/reference/color-and-contrast.md
  • .agents/skills/impeccable/reference/colorize.md
  • .agents/skills/impeccable/reference/craft.md
  • .agents/skills/impeccable/reference/critique.md
  • .agents/skills/impeccable/reference/delight.md
  • .agents/skills/impeccable/reference/distill.md
  • .agents/skills/impeccable/reference/document.md
  • .agents/skills/impeccable/reference/extract.md
  • .agents/skills/impeccable/reference/harden.md
  • .agents/skills/impeccable/reference/heuristics-scoring.md
  • .agents/skills/impeccable/reference/interaction-design.md
  • .agents/skills/impeccable/reference/layout.md
  • .agents/skills/impeccable/reference/live.md
  • .agents/skills/impeccable/reference/motion-design.md
  • .agents/skills/impeccable/reference/onboard.md
  • .agents/skills/impeccable/reference/optimize.md
  • .agents/skills/impeccable/reference/overdrive.md
  • .agents/skills/impeccable/reference/personas.md
  • .agents/skills/impeccable/reference/polish.md
  • .agents/skills/impeccable/reference/product.md
  • .agents/skills/impeccable/reference/quieter.md
  • .agents/skills/impeccable/reference/responsive-design.md
  • .agents/skills/impeccable/reference/shape.md
  • .agents/skills/impeccable/reference/spatial-design.md
  • .agents/skills/impeccable/reference/teach.md
  • .agents/skills/impeccable/reference/typeset.md
  • .agents/skills/impeccable/reference/typography.md
  • .agents/skills/impeccable/reference/ux-writing.md
  • .agents/skills/impeccable/scripts/cleanup-deprecated.mjs
  • .agents/skills/impeccable/scripts/command-metadata.json
  • .agents/skills/impeccable/scripts/design-parser.mjs
  • .agents/skills/impeccable/scripts/detect-csp.mjs
  • .agents/skills/impeccable/scripts/impeccable-paths.mjs
  • .agents/skills/impeccable/scripts/is-generated.mjs
  • .agents/skills/impeccable/scripts/live-accept.mjs
  • .agents/skills/impeccable/scripts/live-browser-session.js
  • .agents/skills/impeccable/scripts/live-browser.js
  • .agents/skills/impeccable/scripts/live-complete.mjs
  • .agents/skills/impeccable/scripts/live-completion.mjs
  • .agents/skills/impeccable/scripts/live-inject.mjs
  • .agents/skills/impeccable/scripts/live-poll.mjs
  • .agents/skills/impeccable/scripts/live-resume.mjs
  • .agents/skills/impeccable/scripts/live-server.mjs
  • .agents/skills/impeccable/scripts/live-session-store.mjs
  • .agents/skills/impeccable/scripts/live-status.mjs
  • .agents/skills/impeccable/scripts/live-wrap.mjs
  • .agents/skills/impeccable/scripts/live.mjs
  • .agents/skills/impeccable/scripts/load-context.mjs
  • .agents/skills/impeccable/scripts/modern-screenshot.umd.js
  • .agents/skills/impeccable/scripts/pin.mjs
  • .github/CODEOWNERS
  • .gitignore
  • .impeccable/design.json
  • AGENTS.md
  • AUDIT_REPORT.md
  • CHANGELOG.md
  • DESIGN.md
  • PRODUCT.md
  • certified-design.pen
  • docs/auth-bsky-pds-fix/plan.md
  • docs/auth-bsky-pds-fix/review-round-1.md
  • docs/auth-bsky-pds-fix/review-round-2.md
  • docs/auth-bsky-pds-fix/review-round-3.md
  • docs/auth-bsky-pds-fix/tsc-baseline.txt
  • docs/badge-response-flow/plan.md
  • docs/badge-response-flow/review-round-1-impl.md
  • docs/badge-response-flow/review-round-1.md
  • docs/current-state/feature-inventory.md
  • docs/current-state/review-round-1.md
  • docs/current-state/review-round-2.md
  • docs/desktop-layout/plan.md
  • docs/desktop-layout/review-round-1.md
  • docs/desktop-layout/review-round-2.md
  • docs/endorsements-indexer-filter/plan.md
  • docs/groups-list-improvements/review-round-1.md
  • docs/groups-list-improvements/review-round-2.md
  • docs/profile-rendering/plan.md
  • eslint.config.mjs
  • lexicons/app/certified/temp/graph/endorsement.json
  • next.config.ts
  • package.json
  • public/assets/otp-email-template.html
  • public/llms.txt
  • skills-lock.json
  • src/app/.well-known/oauth-client-metadata/route.ts
  • src/app/about/page.tsx
  • src/app/activity/[did]/[rkey]/page.tsx
  • src/app/api/auth/callback-handler/route.ts
  • src/app/api/auth/login/route.ts
  • src/app/api/auth/logout/route.ts
  • src/app/api/auth/session/route.ts
  • src/app/api/feedback/route.ts
  • src/app/api/groups/[groupDid]/audit/route.ts
  • src/app/api/groups/[groupDid]/bsky-profile/route.ts
  • src/app/api/groups/[groupDid]/handle/route.ts
  • src/app/api/groups/[groupDid]/members/route.ts
  • src/app/api/groups/[groupDid]/metadata/route.ts
  • src/app/api/groups/[groupDid]/profile/route.ts
  • src/app/api/groups/[groupDid]/role/route.ts
  • src/app/api/groups/[groupDid]/upload-blob/route.ts
  • src/app/api/groups/memberships/route.ts
  • src/app/api/groups/register/route.ts
  • src/app/api/indexer/route.ts
  • src/app/api/notifications/route.ts
  • src/app/api/resolve-did/route.ts
  • src/app/api/resolve-handle/route.ts
  • src/app/api/search-actors/route.ts
  • src/app/api/xrpc/[...method]/route.ts
  • src/app/apps/layout.tsx
  • src/app/apps/page.tsx
  • src/app/connected-apps/layout.tsx
  • src/app/connected-apps/page.tsx
  • src/app/create/layout.tsx
  • src/app/create/page.tsx
  • src/app/dsa/page.tsx
  • src/app/endorsements/layout.tsx
  • src/app/endorsements/page.tsx
  • src/app/error.tsx
  • src/app/feed/page.tsx
  • src/app/global-error.tsx
  • src/app/globals.css
  • src/app/groups/[groupDid]/apps/page.tsx
  • src/app/groups/[groupDid]/edit-profile/page.tsx
  • src/app/groups/[groupDid]/page.tsx
  • src/app/groups/[groupDid]/settings/page.tsx
  • src/app/groups/create/page.tsx
  • src/app/groups/layout.tsx
  • src/app/groups/page.tsx
  • src/app/imprint/page.tsx
  • src/app/layout.tsx
  • src/app/manifest.ts
  • src/app/not-found.tsx
  • src/app/notifications/layout.tsx
  • src/app/notifications/page.tsx
  • src/app/oauth/callback/page.tsx
  • src/app/page.tsx
  • src/app/privacy/page.tsx
  • src/app/profile/[did]/page.tsx
  • src/app/profile/[handle]/page.tsx
  • src/app/profile/page.tsx
  • src/app/robots.ts
  • src/app/search/layout.tsx
  • src/app/search/page.tsx
  • src/app/settings/edit-profile/page.tsx
  • src/app/settings/layout.tsx
  • src/app/settings/my-data/page.tsx
  • src/app/settings/page.tsx
  • src/app/settings/wallet/layout.tsx
  • src/app/settings/wallet/page.tsx
  • src/app/sitemap.ts
  • src/app/styles/components.css
  • src/app/styles/feed.css
  • src/app/styles/layout.css
  • src/app/styles/notifications.css
  • src/app/styles/pages.css
  • src/app/styles/profile.css
  • src/app/styles/tokens.css
  • src/app/terms/page.tsx
  • src/app/welcome/layout.tsx
  • src/app/welcome/page.tsx
  • src/components/badges/response-buttons.tsx
  • src/components/badges/response-menu.tsx
  • src/components/dashboard/custom-domain-modal.tsx
  • src/components/dashboard/username-card.tsx
  • src/components/endorsements/endorsement-row.tsx
  • src/components/endorsements/new-endorsement-panel.tsx
  • src/components/feed/activity-author.tsx
  • src/components/feed/activity-card-skeleton.tsx
  • src/components/feed/activity-card.tsx
  • src/components/feed/activity-contributor.tsx
  • src/components/feed/activity-detail.tsx
  • src/components/feed/activity-feed.tsx
  • src/components/feed/feed-layout.tsx
  • src/components/feed/location-card.tsx
  • src/components/feed/user-feed.tsx
  • src/components/groups/add-org-modal.tsx
  • src/components/groups/handle-search.tsx
  • src/components/groups/membership-sync-modal.tsx
  • src/components/groups/org-settings.tsx
  • src/components/identity-link/identity-link-card.tsx
  • src/components/identity-link/link-wallet-flow.tsx
  • src/components/landing/hero-signin-button.tsx
  • src/components/landing/home-client.tsx
  • src/components/landing/landing-page.tsx
  • src/components/landing/orbiting-logos.tsx
  • src/components/landing/sections/built-for-trust.tsx
  • src/components/landing/sections/faq-accordion.tsx
  • src/components/landing/sections/faq-content.tsx
  • src/components/landing/sections/how-it-works.tsx
  • src/components/landing/sections/partner-apps.tsx
  • src/components/landing/sections/ready-cta-button.tsx
  • src/components/landing/sections/ready-cta-content.tsx
  • src/components/landing/sections/what-you-get.tsx
  • src/components/layout/account-switcher-list.tsx
  • src/components/layout/app-shell.tsx
  • src/components/layout/auth-guard.tsx
  • src/components/layout/bottom-nav.tsx
  • src/components/layout/desktop-left-rail.tsx
  • src/components/layout/desktop-right-rail.tsx
  • src/components/layout/footer.tsx
  • src/components/layout/mobile-sidebar.tsx
  • src/components/layout/navbar.tsx
  • src/components/layout/page-title.tsx
  • src/components/map/map-dynamic.tsx
  • src/components/map/map-skeleton.tsx
  • src/components/map/map.tsx
  • src/components/notifications/notification-row-skeleton.tsx
  • src/components/notifications/notification-row.tsx
  • src/components/profile/avatar-upload.tsx
  • src/components/profile/banner-upload.tsx
  • src/components/profile/profile-client.tsx
  • src/components/profile/profile-edit-form.tsx
  • src/components/profile/profile-endorsements.tsx
  • src/components/profile/profile-groups.tsx
  • src/components/profile/profile-header.tsx
  • src/components/right-rail/news-section.tsx
  • src/components/right-rail/rich-text.tsx
  • src/components/search/people-search.tsx
  • src/components/ui/avatar.tsx
  • src/components/ui/badge.tsx
  • src/components/ui/brandmark.tsx
  • src/components/ui/button.tsx
  • src/components/ui/card.tsx
  • src/components/ui/confirm-dialog.tsx
  • src/components/ui/empty-state.tsx
  • src/components/ui/error-message.tsx
  • src/components/ui/feed-label-pill.tsx
  • src/components/ui/feedback-modal.tsx
  • src/components/ui/input.tsx
  • src/components/ui/loading-spinner.tsx
  • src/components/ui/provider-redirect-overlay.tsx
  • src/components/ui/sign-in-modal.tsx
  • src/components/ui/textarea.tsx
  • src/components/ui/theme-toggle.tsx
  • src/config/__tests__/trusted-evaluators.test.ts
  • src/config/trusted-evaluators.ts
  • src/hooks/use-active-evaluators.ts
  • src/hooks/use-activities.ts
  • src/hooks/use-activity.ts
  • src/hooks/use-attestation-signing.ts
  • src/hooks/use-author-info.ts
  • src/hooks/use-bluesky-follows.ts
  • src/hooks/use-body-scroll-lock.ts
  • src/hooks/use-bottom-sheet-drag.ts
  • src/hooks/use-bsky-posts.ts
  • src/hooks/use-contributor-info.ts
  • src/hooks/use-contributor-information.ts
  • src/hooks/use-endorsements.ts
  • src/hooks/use-followed-dids.ts
  • src/hooks/use-global-feed.ts
  • src/hooks/use-identity-links.ts
  • src/hooks/use-location.ts
  • src/hooks/use-notifications-feed.ts
  • src/hooks/use-org-profile.ts
  • src/hooks/use-own-response-states.ts
  • src/hooks/use-pending-awards-count.ts
  • src/hooks/use-profile-pds.ts
  • src/hooks/use-profile-responses.ts
  • src/hooks/use-profile.ts
  • src/hooks/use-received-endorsements.ts
  • src/hooks/use-scroll-hide-navbar.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Holke Brammer and others added 26 commits May 17, 2026 22:05
The Overview tab's Contributors section used to render the full
list inline. Now caps at 5 rows and surfaces a "See all" link on
the section header (right-aligned, muted) that routes to
`?tab=contributors`. The count pill keeps the true total.

The Contributors tab still shows everyone — the cap only applies
to the Overview preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ooter links

Four small fixes batched together:

  - Extracted `<EditBanner>` (src/components/ui/edit-banner.tsx) —
    single shared component used by both the profile page and the
    cert detail page. Same DOM, same `.profile-edit-banner` class
    (and therefore identical sticky-under-top-bar positioning, 24px
    lateral margin, etc.), so the two surfaces are byte-identical.
  - The cert Overview tab's shortDescription used to live inside
    `cert-detail__headline` (12px gap from the byline). Moved out
    to a sibling section in `cert-detail__main` (24px gap), so the
    shortDescription's top edge now aligns with the first content
    row on the Description / Contributors tabs.
  - The settings page's left-pane menu was jittering during scroll
    because the inner scroll container (`max-height` + `overflow-y`)
    re-laid out at sub-pixel boundaries as the viewport shifted.
    Dropped both: the category list is always short, so a plain
    `position: sticky` with no inner scroll stays pixel-stable.
  - Footer links updated: About / Terms / Privacy / Imprint
    (replacing the previous Apps / hypercerts.org / GitHub mix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small polish items:
  - The cert and profile edit banners use the same <EditBanner>
    component but landed at different vertical offsets: the
    `.cert-detail-page` wrapper has 24px top padding (for non-edit
    content's breathing room) while `.profile-page` has none, so
    the cert banner sat 24px lower. Added a `:has(.profile-edit-banner)`
    rule that drops the wrapper's top padding when the banner is
    present — the banner's own 16px top margin then provides the
    same gap on both surfaces.
  - On the Overview tab's Contributors section, the "See all"
    affordance moved out of the section header (where it shared
    space with the title and count) and into a centered link
    beneath the list. New copy reads "X contributors — see all"
    so the click target is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… apply

The certs tab split was previously exclusive: each record went
into either Created (author) OR Contributed (else). A cert where
the viewer is both author AND contributor only appeared under
Created — and asking the indexer with `_or` and splitting locally
made that hard to fix.

Refactored the data layer to fire two parallel indexer queries
(`where: { did: { eq } }` and `where: { contributor: { eq } }`)
instead of one OR'd query, and return two independent lists. A
cert matching both filters appears in both lists naturally.

Plumbing changes:
  - `fetchUserIndexerActivities` gains a `mode: "authored" |
    "contributed"` option. Default stays "authored" for source
    compat; the hook calls it twice.
  - `useUserIndexerActivities` now returns `{ created, contributed,
    dids, ...}` with independent pagination state per bucket. The
    combined `dids` map is still exposed for consumers that key on
    author DID per URI (the certs feed-layout still uses it).
  - profile-certs drops the local author-vs-contributor split and
    consumes the two lists directly.
  - profile-overview's stat counts and recent-certs digest
    re-derive from the two buckets (digest dedupes across them).

Same indexer limitation as before: strong-ref contributor
identities aren't matched by `contributor.eq` server-side — that's
the separate indexer fix tracked alongside hb-agent/magic-indexer#81.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…settings sync

Profile / social graph
- New Followers tab with Followers/Following sub-tabs (URL-driven)
- Sidebar follower/following counts deep-link into the matching sub-tab
- Follow / Unfollow button with optimistic updates + dedup
- Endorse button moved to sidebar; opens reason-capture modal
- Per-card × revoke on Given endorsements, Following cards, list items
- About tab hidden on individual profiles (org-only)
- Bluesky-import tag treatment on imported profiles (hides Joined date)

Endorsement lists
- New "Lists" section on the Endorsements tab (above Received/Given)
- Create / edit / delete a list (delete also removes linked awards)
- "+ Add people" reuses the multi-endorse modal bound to the list's badge
- List detail view: ←Back + title left-aligned, Edit/Delete on the right
- Compact "No lists yet" line on foreign profiles

Endorsements UX
- PersonCard layout: name / @handle / date / list-name (row 4) stacked
- Reason field on single-target + multi-target endorse modals
  (500-char cap, live counter); list awards skip the reason capture
- Owner-only filter dropdown on Received: hide rejected (default) /
  only rejected / show all; explanatory note about response visibility
- Hooks attach `listTitle` per award (Given + Received)

Indexer-side wins (already deployed)
- Received endorsements: 2 indexer calls instead of per-issuer PDS scan
- Magic-indexer issues filed for nested-where on badge.definition,
  array-element where on collection.items, and per-def awardCount

Projects tab
- Boxed sections with large hero image; "Certs" sub-section per box
- Cert rows show image + title + time period (start–end)
- Owner-only "Create new project" CTA → /project/new placeholder

Settings
- New "Sync social graph" section in personal + group settings; compares
  Certified vs Bluesky follow sets, "Import all" or paginated picker
- Group BFF route /api/groups/[did]/follow for group-scoped follow writes
- Identity-switch flow preserves the pre-signin path (rewrites
  /profile/<old-handle> → /profile/<new-did> for identity-scoped URLs)

Layout / chrome
- Top bar no longer sticky; whole page scrolls together
- Edit banner shared between profile and cert pages; padding/width unified
- Dark mode lightened (zinc-900 surface band)
- Cert image placeholder centered; "Activity" page-title flash removed
- Settings menu jitter fixed
- Modal radius convention codified in DESIGN.md §11:
  new `.app-modal` class returns sign-in chrome to 2px radius
  (sign-in modal stays the intentional 20px exception). Applied to
  every existing dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ession

Adds §15a (Social Graph + Endorsements) covering lexicons, write
helpers, hooks, card/modal patterns, indexer migration state, and the
optimistic-state reconciler.

Adds the modal radius rule to §11 (sign-in keeps the 20px exception;
everything else needs `.app-modal`).

Adds 7 new entries to §22 Common Pitfalls — forgotten `.app-modal`,
clearing optimistic state in `finally`, reverting the new vertical
PersonCard layout, `listTitle` privacy assumption, group follow
writes without `targetDid`, rejected-endorsement privacy, and
static-vs-dynamic route precedence at `/project/new`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
INDEXER_URL, INDEXER_DID, NEXT_PUBLIC_INDEXER_URL,
NEXT_PUBLIC_STADIA_API_KEY, and the group-service URL/DID pair are
all read by production code but were absent from the example. The
most consequential omission is INDEXER_URL: when unset the indexer
proxy falls back through NEXT_PUBLIC_INDEXER_URL to a hardcoded dev
URL (magic-indexer-dev.up.railway.app), so a production deploy that
follows the example would silently route every feed and notifications
query at the dev indexer.

The Stadia entry also documents that the key is bundle-public by
design and that Stadia's intended enforcement is per-domain Referer
allowlist on the Stadia dashboard.

Docs-only; no runtime change in this commit. The matching prod-warn
on missing INDEXER_URL lands in a follow-up indexer-route commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… memoization

Two pre-existing ESLint errors gated tonight's "no new lint errors"
contract. Both have small, semantically-identical fixes:

useMergedDidsMap (use-user-indexer-activities.ts): the helper
reinvented `useMemo` using `useRef` + `setMerged` during render to
get "stable reference unless inputs change." Under StrictMode the
ref mutation persists across discarded renders and the second pass
sees the cached pair and skips the setState, leaving stale state.
Replaced with `useMemo(() => mergeMaps(a, b), [a, b])`. Same
semantic, no setState-in-render, lint-clean. Closes 5 of 6 errors.

useSocialGraphSync.refetch: the prior useCallback depended on
`certified` (the whole object returned by useFollowing — a fresh
object literal each render), and the body had a dead
`bluesky ? Promise.resolve() : Promise.resolve()` ternary. The fresh
dep array defeated downstream memoization and the React Compiler
bailed out. Destructure useFollowing and useBlueskyFollows so the
closures close over individually-stable callbacks; drop the dead
ternary. Closes the 6th error.

Lint baseline goes from 45 problems (6 errors, 39 warnings) to
38 problems (0 errors, 38 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the XSS class from AGENTS.md §22 pitfall #11: user-controlled
URLs were rendered into `<a href={url}>` and `iframe src` without any
scheme allowlist, so a `pub.leaflet.pages.linearDocument` record on
any federated PDS could carry a `javascript:` URI that fires when a
viewer clicks the link in a cert / profile / long-description.

Five sites, one existing helper (`safeHttpUrl` from
`src/lib/utils/safe-url.ts`) applied:

- leaflet-document.tsx renderIframe — iframe fallback `<a>` when the
  host is not in the embed allowlist. `isAllowedEmbedHost` only
  inspects `hostname`, so `javascript:` (no host) fell through to
  the fallback anchor with the raw URI.
- leaflet-document.tsx applyFacets — facet `<a href={linkUri}>` for
  bold/italic/link inline runs. The same `javascript:` payload could
  reach here via a foreign linearDocument record.
- leaflet-iframe-node.tsx — TipTap node-view's unsupported-embed
  fallback `<a>` was the in-editor mirror of the same bug.
- leaflet-editor.tsx handleLinkConfirm — TipTap's Link extension only
  runs `isAllowedUri` on `setLink`/`toggleLink`; the no-selection path
  uses `insertContent` which bypasses validation. Scheme-allowlist
  both branches before write.
- link-dialog.tsx — reject non-http(s) URLs at submit with an inline
  error so the user gets immediate feedback instead of a silent drop.

Defense in depth on the (de)serializer boundaries closes the
re-publish path under the user's identity:

- from-tiptap.ts marksToFeatures — drop the FEATURE_LINK when the
  href fails the allowlist. Without this, a malicious foreign record
  hydrated into the editor and then saved would re-publish the URI
  under the user's DID.
- to-tiptap.ts featureToMark — drop the link mark on rejection so
  the editor surfaces the plain text and the in-memory JSON never
  carries the URI.

Render-side rejections degrade to plain text (`<span>`) rather than
silently dropping the content, so the user can still see the URL
without one-click execution.

CSS: new `.link-dialog__error` rule sized like `.link-dialog__hint`
and themed via `--color-error`.

tsc clean. Lint 38/38 (0 errors). Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Error

AGENTS.md §17 #7 and §24 #8 both state explicitly that "4xx errors
*can* echo upstream messages — those are usually validation a user
can act on." The helper, however, was returning generic strings
("Bad request" / "Forbidden" / etc.) for every status, so every
route using it surfaced opaque errors that masked actionable upstream
detail. A user submitting an invalid group handle, for example, got
"Bad request" instead of the upstream's "Handle must be at least 3
characters". The XRPC proxy already did the right thing (xrpc/route
echoes for 4xx, generic for 5xx); this brings the shared helper in
line with the documented policy and the XRPC proxy precedent.

Also: clamp the upstream-supplied status to the valid HTTP range
(200..599). The function previously trusted any integer on
`err.status` / `err.statusCode`, which would pass through to
NextResponse — caches and browsers handle non-standard codes
inconsistently. Anything outside the valid range now collapses to
500.

5xx behavior is unchanged: still generic message, still logged.
Echoed 4xx messages pass through a redactSecrets pass that strips
Bearer tokens, DPoP material, and bare JWTs that the atproto SDK
occasionally embeds in error messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… extractRouteError logSafe

extractRouteError already logs via logSafe (which strips
JWT/DPoP/Authorization material that the atproto SDK occasionally
embeds in err.cause). The three routes also called
`console.error(label, err)` before invoking the helper, which both
duplicated the log line and bypassed the redactSecrets pass —
defeating the very mitigation logSafe exists for.

Replace with a single extractRouteError call carrying a
route-tagged prefix (AGENTS.md §24 #9 convention) so the resulting
log line is greppable.

Affected: /api/groups/[groupDid]/{profile,metadata,upload-blob}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s-assignment

The activity PUT was the one group BFF write route that didn't
field-allowlist its `record` body. Sibling routes (/profile,
/metadata, /location) all narrow via pickAllowedFields() or a
hand-rolled set; this brings activity into line.

ALLOWED_ACTIVITY_FIELDS mirrors the lexicon's ClaimActivity in
src/lib/atproto/activity-types.ts: title, shortDescription,
createdAt, shortDescriptionFacets, description, image, contributors,
workScope, startDate, endDate, locations, rights. Unknown / future
/ accidental keys on the caller's body are now dropped at the BFF.

CGS may also validate upstream but defense-in-depth at the BFF
matches the pattern established in AUDIT_REPORT.md CS-005 for
/profile and /metadata. Updating the lexicon-aware allowlist becomes
the contract for adding any future activity field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes four issues on the geocode route:

- Auth: GET handler now requires a session DID. Geocode UI is only
  mounted on edit screens (cert / profile / group), so gating is
  invisible to legitimate users and closes the open-internet abuse
  surface (anonymous traffic burning Nominatim quota and rate-
  limiting our egress IP for everyone else).
- 5xx hygiene: the non-2xx and catch branches no longer echo the
  upstream status string. The bodies return generic strings; the
  upstream status moves into a logSafe context so operators can still
  diagnose Nominatim health. Mirrors AGENTS.md §17 #7.
- Input parsing: limit is now Number()+isInteger rather than
  parseInt — rejects "3.7" / "3abc" / leading-whitespace tricks
  consistently instead of silently truncating.
- Server logging: the catch was using bare console.error; switched
  to logSafe with the existing [geocode] prefix.

Also: getAuthenticatedAgent silently swallowed client.restore
failures (deleting the session and returning null) with no log. Every
group BFF route's session-expiry event was invisible in Vercel logs.
Add logSafe matching the XRPC proxy's existing pattern at
src/app/api/xrpc/[...method]/route.ts:152.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_URL in production

Two operability gaps on the indexer proxy:

Mutation gate: the proxy forwarded the entire request body verbatim
to the upstream /graphql endpoint with no operation-type check.
CSRF is enforced (same-origin only) and there's no auth gate by
design (feed is publicly readable), but any same-origin context —
including an XSS payload — could call arbitrary GraphQL operations
through the proxy. The notifications route does this right
(server-held queries + operation allowlist + variable scrubbing);
this commit adds the bare-minimum for the indexer route: parse the
body as JSON, reject any request whose `query` (after leading
whitespace + GraphQL `#` line-comments) starts with `mutation`.
The full operation-allowlist restructure is the right answer but
out of scope for tonight; this closes the obvious abuse path.

Production warn: when both INDEXER_URL and NEXT_PUBLIC_INDEXER_URL
are unset, the route falls back to a hardcoded dev URL
(magic-indexer-dev.up.railway.app). A production deploy that misses
the env var would silently route every feed query at the dev
indexer with no operator-visible signal. Add a module-load
`console.warn` mirroring the existing notifications/route.ts
pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o editor

The value-sync effect compared the new external `value` against
`lastExternalRef.current` — the *prior* external doc. On a controlled
form (the cert-edit and profile-edit inline-edit paths) every
keystroke fires the editor's onUpdate, which sets parent state, which
re-renders this component with a fresh `value` whose
`toInitialDoc(value)` doesn't shallow-match the still-stale
`lastExternalRef`. setContent fires, ProseMirror runs a
`tr.replaceWith(0, doc.content.size, …)`, and the selection /
cursor is destroyed even though `emitUpdate:false` suppresses the
change event.

Compare against `editor.getJSON()` instead — that's the actual
source of truth for "what's currently on screen". When the new
`value` is the round-tripped echo of what the editor just emitted,
the shallow-equal hits and we skip the setContent. External resets
(parent setting a totally different `value`) still flow through.

`lastExternalRef` is removed; the comment block above the effect
explains the previous wrong shape and the new comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When isAuthenticated flipped to false, the effect cleared the
module-level cache but left local state on each consumer untouched.
Components that mounted while a user was signed in kept returning
the prior identity's handle/email until they unmounted and
remounted — visibly stale data in the settings panel, account
switcher, and any consumer of useSession that survives a sign-out.

The initial-state expressions at the top of the hook only gate on
isAuthenticated for *fresh* mounts; existing mounts re-run the
effect but not the initial-state expressions. Add setHandle(null),
setEmail(null), setError(null) to the sign-out branch so existing
mounts observe the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on expiry

Follow-on from the geocode-auth commit: the geocode helper still
used raw fetch, so a 401 from the now-gated /api/geocode would be
silently swallowed (catch-and-return-null), and the user would see
"no suggestions" instead of being told their session expired.

Swap to authFetch per AGENTS.md §22 pitfall #2 — raw fetch on
auth-bearing routes silently fails; only authFetch's 401
interceptor surfaces the session-expiry UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two leaks in the cert inline-edit image lifecycle:

On save, the pending preview URL was *moved* into localImageUrl
(setLocalImageUrl(pendingImagePreviewUrl) + setPendingImagePreviewUrl(null))
without revoking the prior localImageUrl. Every edit-then-save cycle
left the previous mirror's blob URL referenced by nothing,
unrevoked, leaking for the page's lifetime. Now wrap the promotion
in a functional setter that revokes the prior value first.

On unmount, neither URL was revoked. A user who navigates away
mid-edit leaks the pending preview until tab close. Add an unmount
cleanup that revokes both — using refs (not effect deps) so the
cleanup only fires on unmount, not on every state transition. A
deps-based cleanup would revoke the URL we just promoted on the
save path (where pending → null and localImageUrl ← pending in the
same batch), killing the image we just saved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The group-follow PUT hardcoded createdAt to the server's clock,
silently dropping any timestamp the client sent. For social-graph
sync that's wrong: the user's intent for an imported Bluesky
follow is to preserve the original timestamp ("I followed X in
2023"), not to stamp every imported follow with the import time.

Accept an optional createdAt on the body, validate it as a
parseable ISO-8601 string (junk → fall back to server time), and
pass it through to the record. The personal-repo path
(createFollow → /api/xrpc/createRecord) already takes whatever the
client puts in the record body; this brings the BFF route into
parity.

Thread an optional createdAt through createFollow in
src/lib/atproto/follow.ts so callers (use-social-graph-sync's
import flow next) can opt into the original timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The from-tiptap writer wrote any nested list — bullet OR ordered —
into the ListItem's `children` field. The to-tiptap reader hydrates
`children` as a bulletList and only emits an orderedList when
`orderedListChildren.children` is present. Round-trip: user creates
a nested ordered list → reopens it as a nested bullet list. Silent
data-loss visible on the next edit.

Split the writer to route nested bulletList → `children` and nested
orderedList → `orderedListChildren.children`, mirroring the
reader's existing asymmetric handling. The ListItem schema already
has both fields (see src/lib/leaflet/types.ts:65-69).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… isWriting in finally

Two fixes on the social-graph-sync write path:

importDids had no abort path. The serial for-loop awaited
createFollow per DID without checking caller cancellation, so a
user who closed the import modal mid-loop kept writing follows to
their repo and the loop kept calling optimistic addFollow on the
unmounted modal's parent — populating the module-level cache with
rows the user thought they cancelled. Accept opts?: { signal? },
check signal.aborted between iterations. SyncModal now scopes an
AbortController to its lifetime and aborts on unmount.

isWriting could leak true on refetch failure. The previous shape
called setIsWriting(false) outside any try/finally, after a
post-loop `await certified.refetch()`. If refetch threw, the modal
stayed stuck on "Importing…" forever. Wrap the whole import in
try { … } finally { setIsWriting(false) }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t-detail__image rule

Three small CSS hygiene fixes:

--danger fallback: 16 occurrences of `var(--danger, #d44)` across
feed.css, social-graph-sync.css, and profile-endorsements.css. The
`--danger` variable is not declared anywhere in tokens.css or
globals.css, so the rule always resolved to `#d44` — a fixed color
that ignores the light/dark theme split (tokens.css already declares
`--color-error: #ba1a1a` for light and `#f87171` for dark). AGENTS.md
§11 rule 3: reuse the CSS variables. Replace all 16 with
`var(--color-error)`.

100vw in leaflet.css:473: `.long-description-modal { max-width:
min(720px, calc(100vw - 32px)); }` reintroduces the AGENTS.md
pitfall #13 pattern — `100vw` includes the scrollbar width and pushes
the dialog past the viewport edge when a vertical scrollbar is
present. Use `100%` (resolves to the dialog's containing block, the
viewport when showModal()'d) so the math doesn't break.

cert-detail.css duplicate: `.cert-detail__image {}` was defined
twice, with the second occurrence just adding `position: relative;`
needed by the inline-edit absolute-positioned chrome. Cascade merges
them today, but the split is confusing and would silently break if
anyone reordered the file. Move `position: relative;` into the
first declaration; drop the second.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ain)

Patch-within-minor bump. Closes the npm audit "high" advisory on
Next 16.2.3 covering the App Router CSP-nonce XSS chain,
cache-poisoning, middleware bypass, and a handful of DoS vectors.
None of the specific exploits apply to this app today (no
middleware, no CSP nonces, no Image Optimization disk cache use),
but staying on a CVE'd minor is friction we don't need.

eslint-config-next bumped in lockstep to match (16.2.3 → 16.2.6).

Verified: tsc clean, lint baseline unchanged at 0 errors / 38
warnings, next build green. npm audit drops from "1 high + 1
moderate" to "0 high + 3 moderate" (the remaining moderates are
all rooted in postcss <8.5.10 reachable via next's nested
dependency; addressing requires either a postcss bump that depends
on Next dropping the constraint, or a forced resolution. Deferred
as separate dep-hygiene work — `npm audit fix --force` proposes
downgrading next to 9.x which is obviously wrong.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
follow.ts had two inline `data.error || ${fallback}: ${res.status}` blocks
that diverged from cert.ts / location.ts / org-marker.ts / profile.ts
which all use extractError(res, fallback). The string-format difference
("Failed to create follow on group: 503" vs. just "Failed to create
follow on group") leaked into the UI on error.

Normalize on extractError. Also split the !res.ok and missing-uri/cid
branches into distinct throws so the failure modes are
distinguishable in logs ("upstream returned no record reference" is a
shape problem, not a transport error).

This is the minimal slice of the architecture-lens "dual-path write
helper" recommendation (F-11). The full helper extraction was
considered and deferred (see 04-mini-review-3.md) — the body shapes
across the five call sites diverge too much to fit one helper
without each caller still pre-shaping both branches, so the
abstraction would burn complexity for shallow savings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nal review

Full deep-flow trail for the 17 fix commits between ad6668c and
this commit:

- 00-orientation.md         Phase 0 — repo shape, stack, baseline gates
- 01-review-plan.md         Phase 1 — lenses, time budget, stopping rule
- 02-findings.md            Phase 2 — consolidated findings (6 lenses)
- 02-findings-lens-6-perf-a11y.md   My own sequential pass
- 03-implementation-plan.md Phase 3 — triage + deep-flow plans per fix
- 04-mini-review-{1,2,3}.md Phase 4 checkpoints after commits 5, 10, 17
- 05-final-review.md        Phase 6 — fresh-eyes pass over the full diff

Per the overnight brief, the operator should be able to reconstruct
my reasoning from these alone without asking. Per-lens raw transcripts
(Security, Correctness, Architecture, Reuse, API/Ops) are summarised
in 02-findings.md rather than committed as standalone files — they
live in 02-findings.md's "Cross-lens consensus" and finding bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups)
hb-agent added 2 commits May 18, 2026 10:20
…-redesign

Revert "Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups)"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants