Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 269 files, which is 119 over the limit of 150. To get a review, narrow the scope: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (31)
📒 Files selected for processing (269)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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)
…flet XSS fix + 16 follow-ups)"
…-redesign Revert "Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups)"
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 arejectedresponse on the recipient's own PDS and filters the award out of profile read paths.What ships
/notificationsrows where the underlying record is abadge.award. Loud, inline./endorsementsReceived tab. State indicator inside the menu ("Showing on your profile" / "Hidden from your profile" / etc.) plus actions for the current state.badge.award.aria-live="polite", single-click revert).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 viauseSyncExternalStore, 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 ontoapp.certified.badge.{definition,award}from the legacyapp.certified.temp.graph.endorsementlexicon (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.endorsementfor 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.awardrate-limiting on the xrpc proxy — abuse-mitigation, deferred per plan §"Out of scope" D1. Track as a separate security PR.badge.award— backend extension that letsapp.certified.badge.awardfirehose 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).hb-agent/magic-indexer#65. When that lands, the PDS fan-out scan inuseReceivedEndorsementscollapses to a single GraphQL query.hb-agent/magic-indexer#67(PR landed forapp/certified/*) andhb-agent/magic-indexer#68(heads-up fororg/hypercerts/*).Verification
npx tsc --noEmit— 0 errorsnpx eslint src/— 0 errors, 28 warnings (baseline 27; +1 from a latest-ref-in-useEffect pattern inuse-profile-responses; within plan AC#10 ±2 budget)npx next build— green/endorsements"+ New"/endorsementsReceived tab — see A's endorsement🤖 Generated with Claude Code