Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups) - #65
Conversation
Replaces the desktop left rail with a sticky two-row top bar. Row 1 carries the brandmark, current page title, search, Apps + Settings icon links, and the account switcher. Row 2 surfaces the profile tabs (Overview, Activities, Endorsements, Groups) only on /profile/[handle]. Adds an Overview tab as the default profile landing — banner, avatar, name, bio, stat chips, and previews of groups and endorsements. Mobile is unchanged: in-page ProfileHeader and tab strip remain, hidden on desktop via CSS. Right rail keeps News + footer; search moves up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DesktopTopBar reads useSearchParams() to highlight the active profile tab, which deopts statically-rendered pages (/dsa, /terms, /privacy, etc.) and fails the production build with missing-suspense-with-csr-bailout. Wrap the bar in a Suspense boundary in the root layout and add a placeholder fallback that reserves row-1 height so first paint doesn't jump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the Overview tab as a GitHub profile layout. Left sidebar (~296px) carries the avatar, display name, handle, DID, bio, Edit profile / Follow button, a followers placeholder, a link list (joined date, website, Bluesky link), and a tile grid of groups. Right pane carries the banner (Overview only), three stat cards, and digest previews of recent activities and endorsements with links into the dedicated tabs. Drops the desktop right rail entirely and widens the profile-page container to 1280px. Non-Overview tabs (Activities, Endorsements, Groups) constrain to a 720px reading column via .profile-panel--reading so list rows aren't stretched. Mobile keeps the existing in-page ProfileHeader + tab strip; the new sidebar identity block is CSS-hidden below 800px to avoid duplication. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-page .profile-tabs--in-page strip was meant to be desktop-hidden
via CSS, but profile.css loads after layout.css and the base
.profile-tabs { display: flex } overrode the hide rule (same
specificity, later source wins), so the strip showed alongside the
top bar's row 2 tabs.
Deleting the strip entirely is the cleaner fix: the desktop top bar's
row 2 is now the single source of profile tab navigation. Mobile loses
in-page tab nav for now — to be re-added if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Profile tabs are now [Overview, Certs, Projects, Endorsements]: Activities renamed to Certs, Groups dropped from the strip (still in the sidebar grid), Projects added with a new fetcher that lists `org.hypercerts.collection` records on the user's PDS and filters to `value.type === "project"`. Extracts the GitHub-style identity sidebar out of ProfileOverview into a dedicated ProfileSidebar so it renders on every tab — avatar, display name, handle, DID, bio, edit-profile / follow action, followers placeholder, link list, and the groups grid persist as the viewer switches tabs. The 2-column grid lifts up to the profile page (.profile-page__layout) so the sidebar stays put while only the right pane swaps between tabs. The banner remains exclusive to the Overview right pane. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback group-service URL and DID still pointed at the staging Railway service (atproto-group-gate-staging…). Production Vercel env left NEXT_PUBLIC_GROUP_SERVICE_URL / _DID empty, so the empty-string fell through `||` to the staging fallback — silently sending prod traffic to the staging CGS. Bump the fallback to certified-group-service-production.up.railway.app and update the AGENTS.md cross-references. A follow-up env update is still needed on Vercel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The certs-social sync commit (70b9669) overwrote oauth-client.ts and removed the loopback-dev branch documented in AGENTS.md §22 #3a. With PUBLIC_URL=http://127.0.0.1:3000 in .env.local, every login POST threw ZodError 'URL must use the "https:" protocol' / 'ClientID hostname must not be an IP address' because the spec only accepts an https:// client_id or the literal http://localhost loopback exception. Detect loopback dev (NODE_ENV !== 'production' AND PUBLIC_URL is missing or http://) and swap to buildAtprotoLoopbackClientMetadata, which generates the virtual http://localhost?... client_id the spec calls for. The redirect_uri is forced to http://127.0.0.1:<port>/ oauth/callback (the spec inverts client_id host vs redirect_uri host: client_id must be localhost, redirect_uri must be 127.0.0.1 or [::1]). Port is read from PUBLIC_URL, defaulting to 3000. ATPROTO_PRIVATE_KEY is intentionally ignored in loopback dev — the helper hard-codes token_endpoint_auth_method: 'none'. Production code path is unchanged. Verified end-to-end: GET /.well-known/oauth-client-metadata → 200, client_id matches spec POST /api/auth/login → 200 with real auth.certified.one redirect URL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Certs tab
Top bar (DesktopTopBar + mobile Navbar):
- Drop divider between row 1 (chrome) and row 2 (profile tabs).
- Reduce row 1 height 64px → 52px.
- Add a breadcrumb mode to NavbarContext: usePageTitleBreadcrumb({left, right})
renders the title as two separate links joined by " / ", with a subtle
pill-style background on hover (no link underline). Falls back to plain
pageTitle when no breadcrumb is set. Title weight 600 → 450.
- Cert detail page wires the breadcrumb to "@handle / cert-title".
Certs tab (new ProfileCerts component, replacing direct UserFeed render):
- Sub-tab strip: "Created" | "Contributed to" with live count chips.
- Right-aligned toolbar: search input + sort dropdown (newest/oldest/
title A→Z/Z→A). Search filters title+shortDescription client-side;
sort applies client-side to the loaded subset.
- Combined data source: useUserIndexerActivities calls the new
fetchUserIndexerActivities() with the magic-indexer's
`where: { _or: [{did:{eq}}, {contributor:{eq}}] }` filter and splits
results client-side by record DID. Pagination is shared across tabs.
- Cards laid out as a 3-column grid at ≥1100px (2 at 800-1099, 1 below).
Cards are uniform size: square 1:1 image, 2-line title clamp + reserved
min-height, 2-line desc clamp + reserved min-height, meta row pinned
to the bottom. Per-card hover background; empty/error states span all
columns so EmptyState centers in the pane.
- Missing-image fallback: Award icon on a subtle gradient placeholder,
same dimensions as a real image so grid alignment is preserved.
Profile Overview:
- Add a thumbnail to each "Recent certs" item (48×48 square, same image
placeholder as the grid cards).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`authFetch`'s 401 interceptor fired on EVERY 401, which meant any upstream-service failure on a route that just happens to use authFetch would log the user out. After today's CGS switch this surfaced as: log in → avatar appears → `/api/groups/memberships` 401s because the new production CGS host doesn't yet have a `/.well-known/did.json` (so its service-auth JWTs fail audience verification) → 401 interceptor fires → user is signed back out. The intent of the interceptor is to catch atproto-OAuth-session expiry. Limit it to that: only treat 401s from `/api/xrpc/*` (proxied through the user's PDS via OAuth) and `/api/auth/*` (our cookie session) as session-expiry signals. 401s from `/api/groups/*`, `/api/indexer`, `/api/notifications`, etc. propagate to the caller as normal failed responses without touching auth state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment" This reverts commit 7765799.
…d.app) Production has been routing to https://groups.certified.app all along — its build (May 11) baked the value in via Next.js's DefinePlugin from a NEXT_PUBLIC_GROUP_SERVICE_URL env that has since been emptied on Vercel. With the env now empty everywhere, code's fallback is the only source of truth. The fallback pointed at atproto-group-gate-staging.up.railway .app — a SEPARATE Railway service (different IP, different database) that returns a different membership set for the same DID. So preview / fresh dev hits the staging CGS and shows different groups than prod. Switch the fallback to https://groups.certified.app / did:web:groups.certified.app so any build (with or without explicit envs) routes to the actual production CGS. Vercel env vars will also be set explicitly in a follow-up step for belt-and-suspenders. The earlier 7765799 / f371cb4 churn pointed at a third URL, certified-group-service-production.up.railway.app, which has no did:web doc and isn't the live prod CGS. This commit corrects that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…\` form
Magic Indexer's GraphQL resolver returns \`Blob.ref\` as a Go
\`fmt.Sprintf("%v", <map>)\` rendering — literally the string
\`map[\$link:<cid>]\` instead of the bare CID. The GraphQL schema declares
\`ref: String!\`, so callers (us) receive that malformed string. Plugging
it into our blob proxy URL produces a 4xx and the image never loads,
even when the underlying record uses the lexicon-canonical
\`org.hypercerts.defs#smallImage\` shape.
Strip the \`map[\$link:…]\` wrapper inside \`getBlobRefLink\` so any code
path that reads a ref via this helper is unaffected by the indexer's
serialisation bug. Other shapes (\`{ \$link: string }\`, bare CID string,
CID instance) keep working unchanged.
Remove this branch once the indexer fixes its resolver.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ab, sub-page agents
Sidebar (left pane on profile pages):
- Drop the "DID" prefix label; the DID string is self-identifying.
- Add small copy-icon buttons after the handle and the DID.
- Move the bio (`description`) out of the sidebar; it now lives in
the Overview tab's About section.
- Pull the website link up to first position in the details list.
- Render org-only `additionalUrls` after the website when the
profile carries `app.certified.actor.organization` (the page can
pass `isOrg` and `additionalUrls` to the sidebar). No data wiring
yet — props default to off so callers opt in once an org-marker
hook lands.
- Make the GROUPS section header a link to the Groups tab.
- More breathing room between group tiles (5-column grid, 12px gap).
Overview tab:
- New "About" section showing the description above the stat cards.
- 4 stat cards instead of 3, in the order Endorsements / Certs /
Projects / Groups. Endorsements splits received vs given. Certs
splits created vs contributed (data via `useUserIndexerActivities`,
same source as `<ProfileCerts>`). Projects + Given-endorsements
show "—" placeholder until their hooks ship.
Groups tab:
- New profile sub-route: <ProfileGroups>. Same toolbar+grid pattern
as <ProfileCerts> (title + count, search input, sort dropdown,
responsive card grid).
- Wired into the profile page tab strip and into desktop-top-bar's
PROFILE_TABS in the order Overview / Certs / Projects / Groups /
Endorsements.
Certs grid:
- Fixed-track 3-column grid: `repeat(3, minmax(0, calc((100% - 48px)
/ 3)))`. With fewer than 3 certs the cards stay capped at 1/3 of
the container instead of stretching to 50% (or 100%).
Parallel agent contributions also bundled in this commit:
- `cert-detail.css` + rewritten `<ActivityDetail>` (cert detail page
redesigned to match the new aesthetic).
- `profile-edit.css` + rewritten `<ProfileEditForm>` (edit-profile
page redesigned, supports the new `website` + org `additionalUrls`).
- `profile-endorsements.css` + rewritten `<ProfileEndorsements>`
(sub-tabs Received / Given, search + sort, Received default).
- `profile-projects.css`, `project-detail.css` + `<ProfileProjects>`
redesign + new project detail route at
`src/app/project/[did]/[rkey]/page.tsx` with `<ProjectDetail>`,
`<ProjectCard>`, `useProject`, `useProjectItems`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the profile page title (`@handle`) clickable just like the breadcrumb pattern on cert / project pages. To support pages that only have one segment, the breadcrumb shape now allows `right` to be omitted — when only `left` is set the navbar renders a single clickable title (no separator, no second segment) instead of forcing a two-part hierarchy. Both the desktop top bar and the mobile titled-navbar render the single-part variant gracefully. Cert + project pages keep their existing two-part breadcrumbs untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the "—" placeholders with real counts from the existing hooks:
- `useGivenEndorsements` (lives in `use-endorsements.ts`) for the
Endorsements stat's "given" line.
- `useUserProjects` for the Projects stat. Reads from the user's PDS,
filters `org.hypercerts.collection` records by case-insensitive
`type === "project"`.
No new hooks — both already existed from the earlier sub-agent passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…profile + cert-detail redesigns, org marker
Profile sidebar:
- Drop the copy-icon button next to the handle; keep it next to the
DID (where it's actually useful — handles are easy to type).
- Drop the gear-icon "settings" button next to "Edit profile"; the
group settings link now lives elsewhere (still passed through the
`settingsHref` prop for the mobile <ProfileHeader>).
Profile overview:
- Hide the banner block entirely when no banner is set (was showing
an empty placeholder strip).
Joined date now reflects the real `createdAt` on the profile record:
- `/api/resolve-did` route returns `createdAt` from the
`app.certified.actor.profile` record.
- `useUserProfile` consumes it instead of the bogus
`new Date(0).toISOString()` fallback that produced "Joined Jan 1970".
Org-only sidebar fields wired:
- New hook `useOrgMarker(did)` reads `app.certified.actor.organization`
via the XRPC proxy, exposes `{ isOrg, additionalUrls, isLoading }`
with module-level caching + in-flight dedupe.
- Profile page consumes the hook and forwards `isOrg` /
`additionalUrls` into <ProfileSidebar>. Hook returns `false` while
loading so the sidebar stays in non-org mode without flicker.
Parallel agents bundled in this commit:
- Edit-profile page rewrite (`<ProfileEditForm>` + `profile-edit.css`)
— fits the 600px reading column, sticky Cancel/Save footer, back
affordance, section headings, pronouns field restored.
- Cert detail page goes wide: `:has(.cert-detail--wide)` opens the
`.app-shell__content` cap up to ~960px for this page only, two-
column layout under the hero (description left, metadata right),
collapses to single column under ~720px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gs tab, settings hub redesign
Account switcher (top bar):
- Trigger now shows the active account's display name + handle next
to the avatar (collapses back to avatar-only below 1100px to keep
the bar compact on narrow desktops).
Smart links (new <SmartLink> component):
- Detects 14+ services from a URL (X/Twitter, GitHub, Bluesky,
Instagram, LinkedIn, YouTube, Mastodon, Telegram, Facebook,
TikTok, Medium, Threads, Discord, Twitch) and renders a brand
icon + a short display string (e.g. `@handle`, `github.com/owner/
repo`, `linkedin.com/in/<slug>`). Uses Lucide where available;
inline SVGs for brands Lucide doesn't ship.
- Wired into <ProfileSidebar> — replaces the generic Link icon +
bare URL for both `profile.website` and org `additionalUrls`.
- Only http/https URLs are rendered as links; everything else
degrades to plain text (rejects `javascript:` etc.).
- Website also now flows through the data pipeline: resolve-did
route surfaces `website` (and `pronouns`) from
`app.certified.actor.profile`; `useUserProfile` consumes them.
Own-profile Settings tab:
- New tab in the profile tab strip, appears only when the viewer's
handle === the profile's handle. It's a top-bar shortcut: clicking
routes to `/settings` (no in-profile panel).
Settings hub (/settings):
- Full redesign to live in the new layout: 600px reading column,
`@handle / Settings` breadcrumb, back-to-@handle affordance, vertical
list of category rows (Edit profile linked out; Account inline
items for Username/Email/Password; Appearance with ThemeToggle).
- Org-active short-circuit (<OrgSettings>) preserved. TODOs marked
in `page.tsx` for future sub-routes per category.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-pane cert layout
Sidebar groups (left pane on profile):
- One group per row now (vertical list) instead of a 5-column avatar
tile grid. Each row shows the avatar + display name + handle.
- When viewing your own profile, the sidebar consumes the same
`useOrg().groups` source the account switcher uses, so the two
lists can no longer drift. Foreign profiles keep the
`useUserGroups(did)` PDS path (CGS isn't available for them).
- Sidebar accepts `groupsOverride` + `groupsLoadingOverride` props so
the profile page can hand the resolved list down explicitly.
Overview stats order:
- Reordered to match the tab strip (Certs, Projects, Groups,
Endorsements). Overview is the current page, so Certs leads.
Cert detail page — restructured to mirror the profile page layout:
- Two columns: 296px slim left pane + fluid main pane (same
measurements as `.profile-page__layout`).
- Left opens with a 1:1 SQUARE image (was 16:9 hero), then the
author byline, then the small Created / Time period / Work scope /
Rights meta block.
- Main opens with the title + short description, then the
Description section, Contributors, and Locations.
- Reading column on the cert page widens to 1100px (from the prior
960px) so the two-pane layout has breathing room.
- Stacks to a single column under 800px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish
Account switcher chrome:
- Trigger border-radius switched from a full pill (999px) to
`var(--radius)` so it matches the surrounding icon buttons and
search bar. The textual name + handle now sit inside a rectangle
consistent with the rest of the top-bar chrome.
- "Switch to a different account" icon (UserPlus) added to the left
of the log-out icon in the account-switcher dropdown. Clicking it
closes the menu and opens the sign-in flow so the user can land on
a different individual atproto account (the new session replaces
the current one). Wired in both `<AccountSwitcherList>` callers
(desktop top bar + mobile navbar).
Inline profile editing:
- "Edit profile" on the sidebar now toggles `isEditing` rather than
routing to /settings/edit-profile. While editing, the same slots
swap into inputs:
• avatar ─ <AvatarUpload> overlay on the existing circular slot
• banner ─ <BannerUpload> (renders an empty drop zone when not
set, so the affordance is visible)
• display name ─ inline <input> in the sidebar
• about ─ <textarea> in the Overview's About block
• website ─ inline <input> in the Overview
Save / Cancel buttons replace the Edit-profile button. Save reuses
the existing `putProfile`, `uploadAvatar`, `uploadBanner` helpers
from `lib/atproto/profile.ts` and best-effort-evicts the resolve-did
cache so the read-only render reflects the change immediately.
- Pronouns + org additionalUrls are NOT inline-editable yet; both
still live behind /settings/edit-profile (preserved on save).
- Shared `ProfileDrafts` type extracted to
`src/components/profile/profile-inline-edit-types.ts` to keep the
page / sidebar / overview from forming an import cycle.
Settings two-pane:
- /settings restructured into a 296px left menu + fluid right panel
with hash-based selection (#username / #email / #password /
#appearance), default = first item. Active item highlighted via
a subtle pill background; ARIA tablist wiring.
- "Edit profile" dropped from the menu (now lives inline on the
profile page); /settings/edit-profile still resolves as a
fallback. <OrgSettings> short-circuit preserved for active orgs.
- Reading column for this page widens to 1100px via the same
`:has()` scoping the cert detail page uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rts inline
Replace the flat project-card grid on the Projects tab with a
sectioned layout where each project is its own section: header (thumb +
title + count chip + short description + See all →) followed by a 3-up
grid of cert cards that belong to that project. Pattern is the GitHub
user "pinned repos" / Behance "collections" / Spotify "made for you"
shape — scannable top-to-bottom, every section a self-contained
surface with a clear deep-link into the project detail page.
- Each section hydrates the project's `items[]` strong-refs via the
existing `useProjectItems` hook (which already filters non-activity
refs and surfaces resolutions in order), and renders the first
three certs as <ActivityCard>s using the same `.feed-card`
normalisation rules the Certs tab uses. The remaining certs live
on the project detail page; the "See all →" link only appears
when there's more than three.
- Header thumb falls back to a FolderGit2 placeholder on the
project's `banner` / legacy `image` field via the existing
resolveActivityImageUrl helper.
- Cert-count chip optimistic: while items are still resolving we
show a best-effort count of activity-typed entries from
`value.items` so the chip doesn't flash "0 certs".
The old <ProjectCard> is deleted (only ever rendered by this file).
profile-projects.css is rewritten end-to-end (`.profile-projects__*`
class set) — no `layout.css` touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ings scroll-spy + tabs
Account switcher dropdown:
- Switch-account icon swapped from `UserPlus` to `ArrowLeftRight` —
a proper "switch" affordance, not "add new user".
- Per-user log-out icon removed from the user row. Sign-out now
lives in its own full-width row at the bottom of the dropdown,
separated by a divider, with `LogOut` icon + "Sign out" label.
Profile edit mode — Cancel/Save banner:
- When inline-editing, the Cancel/Save buttons no longer live in
the sidebar's action slot. They render in a sticky banner above
BOTH the avatar (left pane) and the banner image (main pane),
spanning the full layout width. Sticky-positioned just under the
top bar so the actions stay reachable while scrolling.
- Save errors and the "Editing profile" label render inline in the
banner.
- Sidebar action row is hidden entirely while editing.
Settings page:
- Restructured to a single long scrolling page: every section
(Username, Email, Password, Appearance) renders at once, stacked.
Left-rail menu becomes a scroll-spy nav — clicking an item smooth-
scrolls to that section; the active item highlights as the section
enters the viewport. Hash-deeplinks (`/settings#email`) still work,
triggering an explicit `scrollIntoView` on mount.
- Profile tab strip (row 2 of the desktop top bar) now also renders
on `/settings`, with the "Settings" tab marked active. Other tabs
(Overview / Certs / Projects / Groups / Endorsements) route into
the signed-in user's own profile so the tab strip stays consistent
across the own-profile context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d project detail
SmartLink:
- Replaced the lucide-react brand icons + the hand-drawn approximate
SVGs with the official Simple Icons (https://simpleicons.org) marks
for every supported service. All paths are single-path 24×24
currentColor SVGs inlined as small React components — no new
dependency.
- twitter.com and x.com both use the X mark now (the post-rebrand
identity); display still uses `@handle` for both. Removed the
`Twitter` lucide bird import entirely.
- Simplified display strings: drop the host prefix on services we
already recognise, since the icon already establishes which
platform it is. GitHub renders `owner/repo` (no leading
`github.com/`); LinkedIn renders just the `<slug>` after `/in/`,
`/company/`, `/school/`; Facebook / Medium / YouTube short-URL
forms all collapse to their slug/handle. Unrecognised hosts
render just the bare hostname (no `/firstSegment`).
Project detail page:
- Rebalanced toward the project (parallel-agent rewrite of
`project-detail.tsx` + scoped CSS rewrite). Top of page is now a
21:9 hero on desktop / 16:9 on mobile, large serif title, lead
short-description, full description as prose (capped to 68ch).
Meta lives in a tight bordered card below (Created / Time period /
Location / Contributors).
- Certs are clearly secondary: a single bordered list with one row
per cert (44px square thumb + title + truncated short-desc +
Award placeholder for missing images). No more full <ActivityCard>
grid on this page — cards belonged on the profile's Projects tab,
not on the project detail.
- `.project-detail--wide` opt-in widens this page's reading column
to 1100px via the same `:has()` scoping the cert detail uses; no
`layout.css` touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cert detail back row
Inline profile edit:
- Avatar stays at its read-only 240×240 position+size in edit mode; a
floating "Change" pill anchored to the bottom-right of the avatar
is always visible (no longer requires hover) so the affordance is
obvious. AvatarUpload's small 64px render is no longer used.
- Banner image now fills the entire banner slot edge-to-edge — the
grey gradient on the outer wrapper is hidden in edit mode so the
image doesn't appear to float on top of a grey strip.
- Website is editable directly inside the sidebar's details list at
the exact spot where it renders read-only as a SmartLink. The
website input was removed from the Overview's About section.
- "Edit profile" button slot is reserved as whitespace in edit mode
(the row stays at its 36px height so the layout doesn't jump).
Sidebar:
- Bluesky icon updated to the current official Simple Icons path
(the earlier path drew a malformed butterfly).
- Followers row gets margin top + bottom so it's clearly separated
from the action row above and the social-links list below.
- The "Joined" entry gets margin-top when it isn't the only item in
the details list so the date reads as its own visual block apart
from the social links.
Cert / project detail:
- The desktop top-bar's row 2 now also renders on `/activity/[…]` and
`/project/[…]` with a left-aligned "← Back" button (router.back()).
Keeps the navigation rhythm consistent across the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a viewer is owner / admin of the group whose profile is being
viewed, the same inline-edit affordances that work on the viewer's own
profile (avatar overlay, display-name input, about textarea, website
input, banner upload, top-of-page Save / Cancel banner) now light up
on the group's profile page too. Non-admin viewers are unchanged.
Wiring:
- `canEditInline = isOwnProfile || isAdminOfThisGroup` on the profile
page. The same `editing` gate flows down into <ProfileSidebar> and
<ProfileOverview> unchanged.
- New `editTargetDid` is set to the viewed group DID when an admin is
editing a group; otherwise undefined. Threaded into the save and
blob-upload helpers.
- `putProfile`, `uploadAvatar`, `uploadBanner`, `uploadBlob` in
`src/lib/atproto/profile.ts` gain an optional `{ targetDid }`
parameter. When set and different from the session DID, the helper
routes through the group BFF endpoints
(`/api/groups/[did]/profile` for the record write,
`/api/groups/[did]/upload-blob` for blob uploads) so blobs land in
the group's repo, not the viewer's. Default path unchanged for
every existing caller.
- Sidebar's `sidebarEditHref` group-admin fallback is dropped —
admins now inline-edit instead of routing out. `mobileEditHref`
keeps the legacy `/groups/[did]/edit-profile` link since the
compact mobile header isn't wired for inline edit yet. The eyebrow
reads "Admin of this group" when applicable.
Deferred (still live on /groups/[did]/edit-profile):
- Org-only additionalUrls + the org-marker setting
- Org metadata (founded date, organization type, etc.) and the
OrgUrls list editor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings page:
- Drop the duplicate "Settings" segment from the top-bar breadcrumb;
the row-2 active "Settings" tab already names the page. The
breadcrumb is now just `@handle`.
- Left rail sticky offset now clears BOTH top-bar rows
(`row1 + row2`) so the menu sits flush below the bar and doesn't
slide off as the main pane scrolls.
- Clicking a menu item now smooth-scrolls to its section AND adds a
transient `.sx-section--flash` class (background pulse, 1.4s) so
the eye lands on the right place.
Inline profile edit clarity:
- Display-name + about-textarea inputs now use the SAME visual
treatment as the website input — `1.5px` solid border in
`--border-hover` over `--bg-elevated`. Old hard-coded `--color-*`
tokens dropped in favour of the design-system vars.
- About textarea auto-grows to fit content via a new
`<AutoGrowTextarea>` wrapper so long bios are never clipped.
- Banner edit slot shows a centred "Click to add a banner" label +
a dashed border when no banner is set — uses `:has(img)` so the
label vanishes the moment an image is uploaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… flash polish - Settings: section-flash now animates only the heading background (no border) - Groups tab: full-width rows with avatar, description and joined date - Groups tab: own-profile "Private" subtab lists CGS-only memberships; per-row Make public / Make private buttons toggle PDS visibility - Projects tab: project becomes the focal point — shortDescription and publish date in the section header, certs collapse to compact rows - desktop-left-rail: pass onSwitchAccount to AccountSwitcherList Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cards - Organization type: multi-select preset chips + free-text "Other" in edit mode; rendered as a row of tag pills below the About section. - Location: free-text name + map pin picker in edit mode; read-only shows a Leaflet map to the right of the About block when coords are set, making the main pane a two-column layout for orgs with a pinned location. Backwards-compatible with the legacy plain-string shape. - Founded date: replaces the generic "Joined ..." sidebar line when present (e.g. "Founded May 2026"). - Endorsement cards: redesigned card layout — avatar + body grid; name / handle and date sit on the same flex row so the date never overlaps the name; 3-line note clamp; owner-only kebab floats top-right with reserved padding so it doesn't crowd the date. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le labels - Endorsement cards: bumped padding-right when the owner kebab is present (28px → 40px) so the date no longer reads as overlapping the floating action menu; softer 12px-radius card surface, subtle shadow on hover for visual lift. - Title: dropped the `@` sigil from every breadcrumb / page-title fallback (profile, settings, edit-profile, activity, project) and from the sidebar h1 fallback for users without a displayName. - Edit profile (orgs): added a "Location" section heading above the pin picker; "Clear pin" button no longer wraps; org-type chip's active state now uses the canonical primary-button token pair (--btn-primary-bg/-fg) so the selected text stays readable instead of using the non-existent --bg-base. - Long description heading renamed to "Description (optional)". - Founded-date sidebar input gets a leading "Founded" label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Settings: extracted the two-pane scroll-spy layout into a shared <SettingsPanel> component; rendered both at /settings (preserved as a deep-link target) and as a real ?tab=settings panel on the user's own profile. The top-bar Settings tab now uses the normal tab-href logic (no more /settings shortcut), so the URL becomes /profile/<handle>?tab=settings consistently. - Banner upload: refactored to mirror the avatar's always-visible Camera button instead of hover-reveal — both affordances now share the same interaction model. Empty banner renders a grey gradient box with the same "Change banner" pill so the upload path is identical with or without an image. - Sidebar edit: added "Main website" / "Additional links" sub-headers above the corresponding inputs so each block has a clear label. - Removed the Groups stat from the profile-overview stat row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The viewer must be currently signed in *as* the entity being viewed — being an admin of a group is no longer enough. For a group, the user must switch into it via the account switcher; for a personal profile, the active org must be null. 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 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 |
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
Summary
Overnight autonomous deep-flow review and improvement pass on the positioning-redesign branch (79 commits ahead of
staging). One CRITICAL fix landed (leaflet XSS class), plus 16 atomic follow-up commits closing 17 of the 22 MUST-FIX / high-medium items the diagnostic surfaced.Branch ends the night with
tscclean,npm run lintat 0 errors / 38 warnings (was 6 errors / 39 warnings), andnpm run buildgreen.The full deep-flow trail lives in
docs/overnight-2026-05-18/:00-orientation.md— Phase 0 repo shape, stack, baseline gates01-review-plan.md— six lenses, time budget, stopping rule02-findings.md— consolidated findings (1 critical, 5 high, 16 medium, 37 low)02-findings-lens-6-perf-a11y.md— my own sequential pass03-implementation-plan.md— triage + deep-flow plans04-mini-review-{1,2,3}.md— checkpoint after commits 5, 10, 1705-final-review.md— fresh-eyes pass over the full diffThe single most important thing to look at
Commit
e43edba—fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editorThis closes a critical stored-XSS class. The leaflet renderer was emitting
<a href={url}>for facet-link URIs and iframe-fallback URLs with no scheme allowlist. AGENTS.md §22 pitfall #11 calls this exact attack out by name. Any atproto-account holder could write apub.leaflet.pages.linearDocumentrecord with ajavascript:URI in a link facet; every signed-in viewer of that profile / cert / long-description executes the script incertified.app's origin.Five render sites + two (de)serializer sites now route through the pre-existing
safeHttpUrl()helper (which was sitting insrc/lib/utils/safe-url.tsexactly for this purpose, with a docstring literally saying "never render<a href={url}>, alwayssafeHttpUrl(url) ?? '#'"). On rejection the surfaces degrade to plain text — content stays visible, attack closes.Items completed (17 commits)
b85d45f65630f3e43edba94ba191eee165d89da494048855bc40481724a8084ac72a8cfc4d7461fd99c6a0479bea2dc45e402fde2122965a952a34308e0691Items deferred and why
These were surfaced by the diagnostic but explicitly NOT fixed tonight:
writeToReposhared helper for the five dual-path write sites (F-11 full) — Sitting down to do it, the body shapes diverge enough acrosscert/profile/location/follow/org-markerthat a single helper would force a discriminated union on callers (more boilerplate than today) or have each caller pre-shape both branches (same as today). The architecture-lens reviewer flagged this as the "single highest-value architecture finding"; reading the call sites in front of me, I'm not sure the abstraction earns its cost. Landed the smaller normalization (Option B, commit 17). Full helper → operator decision./api/indexerfull server-held queries + operation allowlist (F-6 full) — Tonight does the minimum (rejectmutationkeyword). The full restructure is a real refactor that would change feed semantics; defer to a focused PR.listFollowingtruncation surface (F-16) — Hook return shape change + 2-3 consumer UI changes. M-effort; surface change touches widely.useFocusTrapon the new<dialog>modals (F-57) — Native<dialog>.showModal()provides focus containment; the AGENTS.md update saying so is the smallest change but is operator copy.<AppDialog>primitive extracting the 8 modals' shared dialog skeleton (F-58) — Substantial; defer to focused PR.mergeProfilerace as "acceptable for v1"; the same applies elsewhere. Operator decision.postcss <8.5.10reachable via Next.js's nested dep.npm audit fix --forceproposes downgrading Next to 9.x (obviously wrong). Needs either a Next.js drop of the constraint or a forced resolution; defer to dependency-hygiene work.core4.5 GB crash dump at repo root — April audit listed as F-026 low-priority cleanup; per the brief's safety rules, no destructive ops without operator approval.The full WILL-NOT-FIX list with rationale lives in
03-implementation-plan.md.Breaking changes
None. Every commit is additive / hardening / cosmetic. The geocode auth gate (commit 7) is a behavior change for any caller hitting
/api/geocodeanonymously — verified the only consumer is the location-picker UI on auth-gated edit screens (updated toauthFetchin commit 10b).The Next.js bump (commit 16) is a patch-within-minor; no API changes affecting this codebase.
Test plan
javascript:alert(1)and submit — verify the inline error appears and no link is inserted (commit 3 dialog-side guard).view-sourcementally — verify any user-controlled URI that isn'thttp(s)/mailto:/tel:renders as plain text, not an<a>(commit 3 renderer guard).npx tsc --noEmit→ 0 errors.npm run lint→ 0 errors.npm run build→ green. (Repo-wide gates.)Operator decisions needed
Items the final-review pass flagged that I did NOT touch and want your call on:
profile-endorsements.css:1073and:1368— the character-counter--warnclasses usevar(--color-error)(red) after commit 15's--danger→--color-errorsweep. The class name is--warn(which usually means amber) but the original code was a reddish#d44fallback. Pre-existing semantic drift; commit 15 cemented the red. If you wanted amber here, switch these two tovar(--color-warning)(#F5A623 / #fbbf24 in dark). Visual call.createdAtplumbing oncreateFollow(commit 12) — accepts the param now, but the sole caller (useSocialGraphSync.importDids) doesn't pass one becauseuseBlueskyFollowsonly surfacesSet<string>of DIDs. The plumbing is latent — useful when you decide to surface bluesky-follow timestamps too.🤖 Generated with Claude Code