diff --git a/.env.local.example b/.env.local.example index 4c74c896..b634c81b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -24,6 +24,36 @@ COOKIE_SECRET=dev-secret-change-in-production UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Required in production: Magic Indexer GraphQL endpoint and DID. +# +# INDEXER_URL is consumed by the server-side /api/indexer and /api/notifications +# proxies. If unset, the code falls back to NEXT_PUBLIC_INDEXER_URL, and then to +# a hardcoded dev URL (magic-indexer-dev.up.railway.app). A production deploy +# with INDEXER_URL unset will silently route every feed/notifications query at +# the dev indexer — set this explicitly in prod. +# +# INDEXER_DID is required for the notifications JWT `aud` claim. Without it, +# /api/notifications returns 503 and logs a module-load warning. +INDEXER_URL=https://magic-indexer-dev.up.railway.app/graphql +INDEXER_DID= + +# Deprecated alias for INDEXER_URL; still read for backwards-compat. Prefer +# INDEXER_URL above and leave this unset on new deploys. +# NEXT_PUBLIC_INDEXER_URL= + +# Optional: Group service URL and DID (CGS). Defaults to the production CGS. +# NEXT_PUBLIC_GROUP_SERVICE_URL=https://groups.certified.app +# NEXT_PUBLIC_GROUP_SERVICE_DID=did:web:groups.certified.app + +# Optional: Stadia Maps API key for map tiles. When unset, the map falls back +# to Carto tiles (also free for basic use). +# +# Note: This is a NEXT_PUBLIC_ var, so the key is inlined into the client +# bundle — it is NOT secret. Stadia's intended enforcement is per-domain +# Referer allowlist configured on the Stadia dashboard. Set the allowlist +# there to your production domain(s) before relying on the key in prod. +# NEXT_PUBLIC_STADIA_API_KEY= + # Optional: Set to enable confidential client (private_key_jwt) authentication # in production. Ignored in loopback dev mode (the spec mandates # token_endpoint_auth_method: none for loopback clients). diff --git a/AGENTS.md b/AGENTS.md index a183332e..8f6feb42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Certified is a passwordless identity platform built on **AT Protocol** (atproto) - `app.certified.actor.membership` — user-side record of group memberships. - `app.bsky.actor.profile` — fallback profile (for Bluesky discoverability). - `org.impactindexer.link.attestation` — EIP-712 wallet attestation linking an EVM address to a DID. -- **Group service** — a separate atproto service (currently `atproto-group-gate-staging.up.railway.app`) that manages multi-user organizations. The app proxies all group operations through the user's PDS using a custom `certified_group` proxy pattern with custom NSIDs (`app.certified.group.*`). +- **Group service** — a separate atproto service (currently `groups.certified.app`) that manages multi-user organizations. The app proxies all group operations through the user's PDS using a custom `certified_group` proxy pattern with custom NSIDs (`app.certified.group.*`). ## 2. Tech Stack @@ -105,8 +105,8 @@ Source: `.env.local.example` and `src/lib/utils/config.ts`. | `RESEND_API_KEY` | optional | Resend key for `/api/feedback`. | | `RESEND_FROM_EMAIL` | optional | Override "from" header. Defaults to `Certified `. | | `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | optional | Adds WalletConnect connector to the wagmi config when set. | -| `NEXT_PUBLIC_GROUP_SERVICE_URL` | optional | Group service base URL. Defaults to the staging Railway deployment. | -| `NEXT_PUBLIC_GROUP_SERVICE_DID` | optional | Group service DID (for `getServiceAuth` `aud`). Defaults to `did:web:atproto-group-gate-staging.up.railway.app`. | +| `NEXT_PUBLIC_GROUP_SERVICE_URL` | optional | Group service base URL. Defaults to `https://groups.certified.app`. | +| `NEXT_PUBLIC_GROUP_SERVICE_DID` | optional | Group service DID (for `getServiceAuth` `aud`). Defaults to `did:web:groups.certified.app`. | `PUBLIC_URL` is the most consequential variable — it is checked against the `Origin` header on every CSRF-protected route, baked into the OAuth client metadata, and used to build the `redirect_uris` array. If it does not match the deployed domain, sign-in and every POST will fail. @@ -380,6 +380,15 @@ If you need to write a new collection, **add it to `ALLOWED_WRITE_COLLECTIONS`** 3. **Reuse the CSS variables** above; don't hard-code colors or transitions in new rules. 4. **Skip-nav styles** are at the top of `globals.css`. Don't duplicate. +### Modals — the radius rule + +Two-tier convention codified in DESIGN.md §11: + +- **Sign-in modal** (`.signin-modal` only): 20px radius, hero padding — the **only** intentional exception to the 2px system. Reserved for the sign-in surface. +- **Every other dialog**: ``. The `.app-modal` class (in `components.css`) inherits the sign-in chrome (backdrop, animation, close X, focus styling) but overrides `border-radius: var(--radius)` and trims padding. + +If you ship a new modal that extends `.signin-modal` without `.app-modal`, the dialog comes out comically rounded and breaks the visual system. Search for `className="signin-modal"` before merging. + ## 12. Component Conventions - **Internal links:** `next/link`. Don't use `` for in-app routes. @@ -456,6 +465,54 @@ Defined in `src/lib/groups/proxy-agent.ts`: `MAX_SELF_CREATED_ORGS = 5`. Enforced both server-side (in `/api/groups/register`) and client-side (in `useOrgCreationLimit()`). A group is "self-created" when the user's member entry has `addedBy === ownerDid`. The server-side check fetches all memberships and member lists for those groups, then counts. +## 15a. Social Graph + Endorsements + +### Lexicons in play +- `app.certified.graph.follow` — `{subject: did, createdAt, via?}`. Viewer's PDS holds *their* follows; "followers of X" is reconstructed via the indexer (`appCertifiedGraphFollow` with `subject.eq` filter). +- `app.certified.badge.{definition, award, response}` — endorsements + lists. A **list** is a `badge.definition` with `badgeType: "endorsement"` and `title !== "Endorsement"`. The reserved `"Endorsement"` title backs the regular endorse flow. +- Allowlist any new collection in `ALLOWED_WRITE_COLLECTIONS` in `src/app/api/xrpc/[...method]/route.ts` — silent 403 otherwise. + +### Write helpers +- `createFollow(ownDid, subjectDid, { targetDid? })` — XRPC for personal, BFF (`/api/groups/[did]/follow`) when `targetDid` set. Mirror this `targetDid` opt-in for any new group-aware write. +- `createEndorsementAward(ownDid, subjectDid, note?)` — default endorsement; lazy-ensures the default definition. +- `createListAward(ownDid, subjectDid, badge: StrongRef)` — award under a specific list. Skip ensure-def; caller passes the list's strong ref. +- `createListDefinition` / `updateListDefinition` / `deleteListAndAwards` — list CRUD; delete walks every linked award first so the def-delete never orphans records. +- `BADGE_AWARD_NOTE_MAX = 500` enforced in `writeBadgeAward` and again in every UI surface that captures a note. The UI also clamps via `maxLength` + `slice` (belt-and-suspenders). + +### Hooks (own + foreign profiles) +- `useFollowing(did)` — PDS listRecords; exposes `addFollow` / `removeFollow` for optimistic updates. +- `useFollowers(did)` — indexer `appCertifiedGraphFollow(where: { subject })`; dedupes by follower DID; exposes `addFollower` / `removeFollower`. +- `useGivenEndorsements(did)` / `useReceivedEndorsements(did, { includeRejected? })` — both attach `listTitle` per award (`undefined` for default endorsements). `includeRejected` defaults to false; pass true on the owner view so the response filter dropdown can switch between Hide rejected / Only rejected / Show all. +- `useEndorsementLists(did)` — definitions + awards on one repo, grouped by def URI. Exposes `createList` / `updateList` / `deleteList`, all optimistic. `listAwards` here is paginated only by the PDS' default page (no full walk yet). +- `useSocialGraphSync(did, { ownDid, targetDid })` — composes `useFollowing` + `useBlueskyFollows`; returns `inBoth` / `onlyCertified` / `onlyBluesky` sets plus an `importDids(dids)` batch writer. + +### Card / modal patterns +- `` in `profile-endorsements.tsx` (and a parallel one in `profile-followers.tsx`) is the shared row used by Received/Given/Followers/Following. Layout: name → @handle → date → optional `listTitle` pill → optional note. Top-right `menu` slot is reserved for the × revoke / kebab / etc. Don't restore the right-aligned date. +- `` is callback-driven via `onEndorse(did, note?)`. It serves three flows: regular endorse (with `requireReason`), list `+ Add people` (skip reason — list is the reason), future awards (just supply a different `onEndorse`). +- `` is the single-target reason capture used by the sidebar Endorse button. Pops up *before* the write, never after. +- All new dialogs use ``. See §11 modal radius rule. + +### Indexer queries +All four social-graph / endorsement hooks already target the post-#87 / #88 / #89 magic-indexer schema: +- `appCertifiedBadgeAward.badge.{badgeType, …}` nested-where is live; **`useReceivedEndorsements` still uses the 2-call workaround** (one indexer for awards, one for endorsement-typed definition URIs). Migrating to the nested-where is a self-contained client change. +- `appCertifiedHypercertsCollection.items.itemIdentifier.uri` array-element where is live; `useCertProjects` could swap from PDS-scan-(same-DID-only) to a single cross-DID indexer query. +- `AppCertifiedBadgeDefinition.awardCount` is live; `useEndorsementLists` could drop its `listAwards` round-trip and read counts directly. + +If you touch one of these hooks, prefer the nested-where shape — search the file's comments for "round-trip" to find the migration notes inline. + +### Optimistic state — the pattern +Every follow / endorse / unfollow button uses the same shape: + +```ts +const [optimistic, setOptimistic] = useState(null) +const effective = optimistic ?? parentValue +useEffect(() => { + if (optimistic !== null && parentValue === optimistic) setOptimistic(null) +}, [parentValue, optimistic]) +``` + +Don't clear `optimistic` in `finally` — the parent's refetch may lag the PDS write, and clearing too early snaps the button back to a stale value for a frame. The `useEffect` reconciler clears the override only when the parent confirms. + ## 16. Identity-Link / Wallet Attestation **Goal:** prove a DID controls an EVM address (and vice versa) by signing an EIP-712 message with the wallet and storing the attestation in the user's PDS. @@ -798,6 +855,13 @@ certified-app/ 13. **`100vw` in CSS** — causes horizontal scroll when a vertical scrollbar is present. Use `100%`. 14. **Treating `next.config.ts`'s `serverExternalPackages: ["@atproto/oauth-client-node"]` as optional** — it's not. Without it, the OAuth client fails to bundle correctly for serverless. 15. **`ATPROTO_PRIVATE_KEY` / JWKS coupling** — if you set `ATPROTO_PRIVATE_KEY`, the OAuth client switches to confidential auth and the published `oauth-client-metadata` includes a `jwks_uri`. Removing the var without updating the registered metadata can desync clients. +16. **Forgetting `.app-modal` on a new dialog** — every modal except the sign-in surface needs ``. See §11 modal radius rule. The 20px chunky-modal regression is the symptom. +17. **Clearing optimistic state in `finally`** — see §15a "Optimistic state — the pattern". The parent's refetch lags the PDS write; clear via the parent-value-caught-up `useEffect` instead. +18. **Reverting the PersonCard layout to right-aligned date** — Received/Given/Followers/Following cards intentionally stack name → @handle → date → listTitle. The previous "name on left, date on right" layout breaks the new `listTitle` row 4. +19. **`listTitle` privacy leak** — `useReceivedEndorsements` returns `listTitle` to ALL viewers (the def title is public on the issuer's repo). That's fine for endorsements. Don't accidentally apply the same logic to private metadata. +20. **Group follow writes via the personal XRPC proxy** — `createFollow(ownDid, subjectDid)` without `targetDid` writes to the PERSONAL repo, even when acting-as-group. Pass `{ targetDid: groupDid }` to route through `/api/groups/[did]/follow`. +21. **Hiding rejected endorsements from non-owners** — `useReceivedEndorsements` default keeps the privacy contract (foreign viewers never see rejected). Only pass `{ includeRejected: true }` on owner-side surfaces, and filter client-side from there. +22. **Static segments under dynamic routes** — `/project/new` lives at `src/app/project/new/page.tsx` alongside `[did]/[rkey]`. Static wins (and `[did]/[rkey]` is two segments so `/project/new` wouldn't match it anyway), but if you change the dynamic pattern to single-segment make sure `new` still wins. ## 23. Adding a New Feature — Checklist diff --git a/DESIGN.md b/DESIGN.md index 3da4f29a..88490b0d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -211,7 +211,14 @@ All icons from **Lucide React**. Conventions: ### Modals -**Standard modal** (sign-in, domain): Centered on desktop, full-width on mobile. `bg-elevated`, 1px `border-default`, `shadow-lg`. Entry animation: `modalFadeIn` (backdrop 200ms) + `modalSlideUp` (content 300ms, spring easing). Sign-in modal uses 20px radius — the only intentional exception to the 2px system. +**Standard sign-in modal** (`.signin-modal` only): Centered on desktop, full-width on mobile. `bg-elevated`, 1px `border-default`, `shadow-lg`. Entry animation: `modalFadeIn` (backdrop 200ms) + `modalSlideUp` (content 300ms, spring easing). 20px radius + 40px hero padding. This shape is **reserved for the sign-in surface** — it's an intentional exception to the 2px system because sign-in is a once-per-session, branded surface. + +**App modals** (every other in-app dialog — endorse-people, create-list, sync-social-graph, future): use ``. The `.app-modal` modifier inherits the sign-in chrome (backdrop / animation / close button / focus styling) but overrides: + +- `border-radius: var(--radius)` (2px — matches cards, dropdowns, inputs). +- Padding trimmed to `16px 20px 12px` header / `0 20px 20px` body — denser than the sign-in surface, which is right for form-style and list-style modals. + +If you're building a new modal that isn't the sign-in flow, **always** add `app-modal` alongside `signin-modal`. Forgetting it makes the dialog read as a chunky sign-in surface and breaks the 2px system everywhere else. **Bottom sheet** (mobile account switcher, mobile feedback): Fixed to bottom, draggable handle, swipe-down-to-dismiss. `bg-elevated`, top border-radius. Expandable via swipe-up. diff --git a/docs/overnight-2026-05-18/00-orientation.md b/docs/overnight-2026-05-18/00-orientation.md new file mode 100644 index 00000000..988b1478 --- /dev/null +++ b/docs/overnight-2026-05-18/00-orientation.md @@ -0,0 +1,246 @@ +# 00 — Orientation + +**Date:** 2026-05-18 (UTC overnight pass) +**Branch:** `feat/positioning-redesign` (HEAD `ad6668c`) +**Operator:** asleep; this pass is autonomous. + +--- + +## How to read this doc + +Phase 0 per the overnight brief: factual map of the project, no judgments yet. Phase 1 (review design) lives in `01-review-plan.md`; findings in `02-findings.md`; etc. + +--- + +## 1. What this project is, in plain words + +Certified is a Next.js application that lets people and organizations publish "certs" — long-form work records — to their own ATProto (PDS) repository, then aggregate, surface, and endorse them through a federated network. It mirrors and extends Bluesky's social graph (handles, profiles, follows) with a parallel "certified" overlay (`app.certified.*` lexicons) plus Hypercerts-derived activity claims (`org.hypercerts.claim.*`). It is also a tooling layer for groups/organizations who want to act collectively — create cert records under a group DID, run member endorsements, and manage a shared profile and locations. + +The product currently in this branch is the result of a "positioning redesign" — a 79-commit feature branch (since the last merged staging) that landed: a profile hub with multiple tabs (Overview/About/Certs/Projects/Groups/Endorsements/Followers/Settings), a TipTap-based "leaflet" rich-text editor for long descriptions, a social-graph sync UI for migrating Bluesky follows into the Certified `graph.follow` collection, inline-edit chrome on the cert detail page, a location/geocode binding (with Leaflet map and EPSG:4326 SRS), and a redesigned settings surface for both personal and group identities. + +--- + +## 2. Stack & runtime + +| Layer | Choice | +|---|---| +| Framework | Next.js **16.2.3** (App Router, Turbopack production build) | +| Language | TypeScript strict (ES2017 target, bundler resolution, `@/*` alias) | +| UI | React 19, plain CSS (per-feature files in `src/app/styles/`), Tailwind for utilities, no CSS modules / styled-components | +| Auth | ATProto OAuth via `@atproto/oauth-client-node`, Redis-backed session/state store (Upstash), HMAC-signed `certified_session` cookie | +| Persistence (server) | Upstash Redis (sessions, OAuth state, token bundles) — **no application database** | +| Persistence (user data) | The user's own PDS (federated); also CGS (group service) and Magic Indexer (GraphQL) | +| Rich text | TipTap 3 (`@tiptap/react`, `starter-kit`, `link`, `placeholder`) | +| Maps | Leaflet + react-leaflet 5 | +| Email | Resend | +| Caching | None at the HTTP/data layer (no React Query / SWR); module-level promise dedup in a few hooks | +| Lint | ESLint flat config extending `eslint-config-next/core-web-vitals`; `react-hooks/set-state-in-effect` downgraded to `warn` | +| Tests | **None.** One placeholder `*.test.ts` in `src/config/__tests__/`; `tests/*.test-plan.md` are prose checklists. | +| CI | **No GitHub Actions.** The only automated check is Vercel preview/production deploys. `npm run build` is the manual gate. | +| Deploy | Vercel (project `certified-app`, team `hypercerts-foundation`) | + +--- + +## 3. Repo shape + +``` +src/ + app/ Next.js App Router pages + route handlers + api/ 27 API routes: auth, xrpc proxy, groups BFF, indexer proxy, resolve helpers, geocode + styles/ 18 plain-CSS files (largest: layout.css 3334 lines, feed.css 2155, components.css 1910) + profile/[handle]/ Profile hub (~1145-line page.tsx, tab orchestration) + activity/[did]/[rkey]/ Cert detail + project/[did]/[rkey]/ Project detail + groups/, settings/, search/, feed/, oauth/callback, … + components/ + profile/ 15 files, 272 KB — heaviest feature (endorsements 1152, overview 991, sidebar 904, followers 740) + layout/ 11 files, app shell, navbar, top bar, bottom nav, footer, account switcher + feed/ 12 files, activity-detail (901), activity-card, cert-byline, cert-locations-map, cert-projects + leaflet/ 7 files, TipTap editor + image/embed nodes + link/embed dialogs + settings/ sync-social-graph-section (690), settings-panel (~250) + groups/, map/, project/, ui/, … + ui/ 18 shared primitives (button, card, modal-like, avatar, input, textarea, smart-link, …) + lib/ + atproto/ activity, badges, cert, follow, indexer, location, profile, types + auth/ oauth-client, session, stores, fetch (authFetch), post-signin, auth-context + groups/ org-context, org-marker, org-types, types, constants, proxy-agent, sanitize, use-org-limit + leaflet/ tiptap ↔ linearDocument conversion, embed-url, guards, types + locations/ geocode helpers + navbar-context, notifications-context, providers, … + hooks/ ~30 hooks. Notable: use-user-profile, use-user-groups, use-endorsements, + use-received-endorsements, use-following, use-followers, use-cert-projects, + use-project-items, use-org-marker, use-user-indexer-activities, + use-social-graph-sync, use-rights, use-session +lexicons/ + app/certified/temp/graph/endorsement.json Single committed lexicon. The badge/award/response and + graph.follow/actor.{profile,organization,membership} + and org.hypercerts.claim.{activity,rights,location} + families are referenced from code but not in this tree. +docs/ + positioning-redesign/, ... Plan + review-decision docs for the in-flight redesign + overnight-2026-05-18/ This pass +tests/ + groups.test-plan.md, notifications.test-plan.md Prose checklists only. +AGENTS.md 1062 lines of conventions, security rules, deep-flow process, pitfalls. +DESIGN.md ~37 KB of design tokens & UX conventions. +``` + +Approximate source-line count under `src/`: 70k (rough; the redesign branch alone added ~24k lines). + +--- + +## 4. Architectural shape + +### Entry points / rendering + +- Root layout (`src/app/layout.tsx`) mounts the provider tree: `ThemeProvider → AuthProvider → OrgProvider → NotificationsProvider → NavbarProvider → FeedbackProvider`. Inside lives ``, a Suspense-wrapped ``, `
` with ``, ``, ``, and a global ``. +- The vast majority of pages are **client components** (`"use client"`); only a handful of legal/info pages are server-rendered. Dynamic routes like `/profile/[handle]` and `/activity/[did]/[rkey]` use the Next 16 `params: Promise<{...}>` pattern. +- There is **no `middleware.ts`** at root. AGENTS.md §20 mentions a `src/proxy.ts` for `/` → `/welcome` redirection in Next 16; it is referenced but not currently in the source tree (was removed or never landed on this branch — `/` is handled by client-side redirect in the page). + +### Data plane + +Three external services, all reached via same-origin API routes (server proxies, not direct browser → upstream calls): + +1. **The user's PDS** — proxied through `/api/xrpc/[...method]`. The proxy restores the user's `OAuthSession` from Redis on every call, validates allowlists (collection writes, blob MIME and size), sanitizes 5xx errors. Public reads (handle resolve, search) go through `/api/resolve-handle`, `/api/resolve-did`, `/api/search-actors`. +2. **CGS** (group service, default `https://groups.certified.app`, DID `did:web:groups.certified.app`) — proxied through `/api/groups/[groupDid]/{profile,activity,location,follow,upload-blob,members,role,handle,metadata,bsky-profile,audit}`. Used for any write that targets a group repo (the user is not the repo owner, so the BFF issues a service-auth JWT on the user's behalf). +3. **Magic Indexer** (GraphQL, `INDEXER_URL` or `NEXT_PUBLIC_INDEXER_URL`) — proxied through `/api/indexer`. Queries: `orgHypercertsClaimActivity`, `appCertifiedBadgeAward`, `appCertifiedBadgeDefinition`, legacy `appCertifiedTempGraphEndorsement`, and a planned `appCertifiedGraphFollow`. + +Client code never calls upstream services directly. Hooks fetch from the same-origin proxies via `authFetch()` (which intercepts 401s into the auth-expiry UI). + +### Auth model + +- **OAuth tokens never leave the server.** They live in Upstash Redis (stored by `NodeOAuthClient`'s session store, keyed by DID), refreshed on demand by `getOAuthClient().restore(did)`. +- The browser carries only a signed cookie `certified_session=.` (httpOnly, secure in prod, sameSite=lax). The session id maps to a DID via Redis (`session:did:`, 30-day TTL). +- HMAC comparison is `timingSafeEqual`. CSRF check on mutating routes compares `Origin` against `PUBLIC_URL`. +- A "dev loopback" path (`oauth-client.ts`) auto-switches to `buildAtprotoLoopbackClientMetadata` when `NODE_ENV !== "production"` and `PUBLIC_URL` is missing or `http://`. This is the only way OAuth works locally without a tunnel. + +### Group / organization model + +- A "group" is a DID with its own PDS-style repo. Members hold `app.certified.actor.membership` records on their own repo referencing the group DID. +- `OrgProvider` tracks the currently active identity (personal DID or a group DID). Most write paths take a `targetDid` and route accordingly: equal-to-self → XRPC proxy on the user's own repo; else → CGS BFF. This routing decision is repeated across `badges.ts`, `cert.ts`, `follow.ts`, `location.ts`, `profile.ts`, `org-marker.ts`. + +### CSS + +- 18 plain-CSS files under `src/app/styles/`. BEM-style class names (`.app-shell__content`, `.profile-endorsements__list-item`). Imported per-route from page or component files (no global registration). +- Tokens (CSS custom properties) in `tokens.css`. Dark theme via `data-theme="dark"` on ``. +- Tailwind is used for utility classes and a small `status` color set, not for layout. + +--- + +## 5. Conventions, idioms, and bets (per AGENTS.md and observed code) + +- **`authFetch` not `fetch`** for every authenticated XRPC call (only `authFetch` surfaces 401 → re-auth UI). +- **CSRF first** in any mutating route (`checkCsrf(request)`), then auth check via `getSessionDid()` or `getAuthenticatedAgent()`. +- **Repo ownership on writes** — `body.repo` must equal session DID; cross-repo writes use the CGS proxy pattern, not the XRPC proxy. +- **Collection allowlist** — only the small `ALLOWED_WRITE_COLLECTIONS` set may be written via XRPC; new collections must be added there explicitly. +- **Sanitize input twice** (client + server), allowlist URL schemes (`http:`, `https:`, `mailto:`, `tel:`) before rendering `href`. +- **Sanitize 5xx errors** — never echo upstream PDS error messages on 5xx; 4xx may echo (usually validation). +- **Deep flow** for substantial work (AGENTS.md §26): plan in `docs//plan.md` with alternatives, parallel reviewer agents per lens, atomic commits with `Co-Authored-By:` trailer, Draft PR, operator merges. The brief tonight overrides only the branching choice (work on `feat/positioning-redesign`, not `staging`). +- **Modals use ``** to inherit the "modal radius" rule (DESIGN.md `--radius-modal`). +- **Optimistic-state pattern** (AGENTS.md §15a) — for endorsement responses and follows: clear optimistic state via a parent-value-caught-up `useEffect`, not in the write's `finally` block. +- **Type validation is hand-written guards, not Zod.** No `zod` import anywhere. Defense is at the API route boundary (typeof, allowlists) and on the client at parse sites. + +--- + +## 6. Quality-gate baseline (captured before any change tonight) + +After running `npm install` (the local `node_modules` was 6 days stale and lacked the recently-added tiptap/leaflet deps): + +| Gate | Status | Notes | +|---|---|---| +| `npx tsc --noEmit` | **PASS** (exit 0) | Fully clean. | +| `npm run lint` | **45 problems: 6 errors, 39 warnings** | Pre-existing baseline — see breakdown below. Must not increase. | +| `npm run build` | **PASS** (with dummy env vars in `.env.local`) | Compiled in 4.0s, 41/41 static pages, all 27 API routes accounted for. | +| `npm audit` | 2 vulnerabilities: 1 high (Next 16.2.3 — patched in 16.2.6), 1 moderate (postcss <8.5.10) | Both are dependency advisories, not custom code. Notable. | + +### Lint baseline detail + +6 errors: +- `src/hooks/use-social-graph-sync.ts:77:31` — `Compilation Skipped: Existing memoization could not be preserved` (React Compiler couldn't preserve a useMemo). +- `src/hooks/use-user-indexer-activities.ts:188-189` — **5 instances** of `react-hooks/refs` "Cannot access refs during render". + +39 warnings — mostly `react-hooks/set-state-in-effect` (a legitimate pattern for SSR mount-sentinel / async-fetch cancellation / reset-on-dep-change, per AGENTS.md §3) and a handful of `react-hooks/refs` warnings. Nothing security-shaped. + +**Tonight's rule:** every commit must keep `tsc` clean and not regress the lint count past `6 errors / 39 warnings`. Build must remain green. + +--- + +## 7. Env vars + +Declared in `.env.local.example`: +- `NEXT_PUBLIC_PDS_URL` (required), `PUBLIC_URL` (required), `COOKIE_SECRET` (required prod), `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` (required), `ATPROTO_PRIVATE_KEY` (optional confidential client), `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`, `NEXT_PUBLIC_GROUP_SERVICE_URL`, `NEXT_PUBLIC_GROUP_SERVICE_DID`. + +Referenced in code but **not in `.env.local.example`**: +- `NEXT_PUBLIC_STADIA_API_KEY` (map tiles) +- `NEXT_PUBLIC_INDEXER_URL` / `INDEXER_URL` (indexer GraphQL) +- `INDEXER_DID` (service auth target) + +This drift is a real finding — defer evaluation to Phase 2. + +--- + +## 8. Branch state (vs. staging, vs. main) + +``` +feat/positioning-redesign ad6668c ← tonight's HEAD + ↑ 79 commits ahead of staging + ↑ contains all of staging (no rebase needed) +staging ← merge target for tonight's Draft PR +main ← production +``` + +The previous Draft PR from this branch into staging — PR #63, "Positioning redesign — implement docs/positioning/brief.md (14/16 tracks)" — was **closed** (not merged) on 2026-05-16. 79 commits have landed since the PR opened (most of them after closure). The closure does not appear to be a rejection of the work; rather it looks like the operator continued iterating directly on the branch and intends to open a fresh PR when ready. Tonight's Draft PR will be the fresh one. + +Recent thematic clusters (since PR-63 close, by commit subject): +- **Profile redesign** — social graph sync, endorsements lists, projects redesign, settings sync; About tab; Groups tab gating; sidebar refinements; inline edit form. +- **Cert detail** — tabbed Overview/Description/Contributors; inline edit (title, short desc, image, description); banner alignment; project section; gating Edit on owner/admin role; map showing geojson polygons. +- **Leaflet editor** — TipTap editor with custom image/embed nodes; YouTube/Vimeo embed; CSP allowlist; image upload; link dialog (replaces `window.prompt`); rounded-modal scroll fix. +- **Layout** — shared `EditBanner`, content alignment, sticky stable, minimal GitHub-style footer. +- **Locations** — two-way location bind, `app.certified.location` record, autocomplete dropdown, EPSG:4326 SRS. +- **Map / geocode** — geocode route, dynamic map, autocomplete. +- **Groups settings** — two-pane layout to match personal. + +--- + +## 9. Interpretations (things I would normally ask the operator) + +The brief is autonomous and the operator is asleep. I noted these interpretive calls in the morning hand-off doc: + +1. **"Implement on `staging`" vs. the named branch.** The overnight prompt template says implement on staging; the operator's actual instruction names `feat/positioning-redesign`. I treat the named branch as authoritative. Atomic commits go to that branch; the Draft PR at the end targets `staging`. +2. **PR #63 closed-not-merged.** I interpret this as "iteration continued past the original PR boundary, fresh PR to come." I open a fresh Draft PR rather than try to reopen #63. +3. **Commits in this branch were authored as `holke@Holkes-MacBook-Pro.local`.** The CLAUDE.md note about Vercel git-author validation references that other emails make the Vercel check fail. Local git config here is `103380539+holkexyz@users.noreply.github.com`. That noreply maps to GitHub user `holkexyz` and PR #63's preview build is `Ready`, so the same Git author validation should continue to succeed. If the Vercel check fails on tonight's push because of git author, I'll re-author my commits with `--reset-author` against the prior parent rather than push more commits on top. +4. **Quality gate: build is the gate; no GitHub Actions exist.** This means tonight's "make CI green" is "make `npm run build` succeed and don't regress lint count." There is no test suite to pass. +5. **No production data in `.env.local`.** I wrote a `.env.local` with dummy values to enable `next build` to instantiate route modules. The file is gitignored. I will not commit it. +6. **The 4.5 GB `core` dump file at repo root** is a crash dump from a prior session (May 16). I am not touching it tonight — out of scope. + +These are also flagged in the final hand-off so the operator can confirm or correct them in the morning. + +--- + +## 10. Surface for review (what's likely in scope tonight) + +Without judging quality, the high-attention areas this branch has changed and that warrant lens-by-lens review are: + +- The **dual-path write routing** (`targetDid`-based XRPC vs CGS) in `badges.ts`, `cert.ts`, `follow.ts`, `location.ts`, `profile.ts`, `org-marker.ts` — copy-pasted shape, error handling shape, route contract assumptions. +- The new API routes added on this branch: `/api/geocode`, `/api/groups/[groupDid]/{activity,follow,location}`. CSRF, auth, body validation, error sanitization, allowlist coverage. +- The four largest UI files (profile-endorsements 1152, page.tsx 1145, profile-overview 991, profile-sidebar 904) for correctness, accessibility, state-management coherence, and possible extraction without scope blow-up. +- The TipTap leaflet editor + linearDocument conversion (`from-tiptap.ts`, `to-tiptap.ts`, `embed-url.ts`, `guards.ts`) — URL-scheme allowlist, embed-URL parsing, sanitization at conversion boundaries. +- The social graph sync flow (`use-social-graph-sync.ts`, `sync-social-graph-section.tsx`) — write idempotency, dedup, error handling, optimistic state. +- `use-user-indexer-activities.ts` — known lint baseline error site (5 × `react-hooks/refs`). +- `use-social-graph-sync.ts` — known lint baseline error site (1 × React Compiler memoization). +- The 12-line CSP in `next.config.ts` — frame-src now includes YouTube/Vimeo (for leaflet embeds); verify no regression in `img-src`, `connect-src`, `script-src` for any of the new flows. +- The Next.js advisory (16.2.3 → 16.2.6 patch bump) — within-minor, but worth Phase 2 evaluation. + +These are scoping notes for the review designer in Phase 1, not findings. + +--- + +## 11. Confidence + +I am confident on the overall shape: stack, route inventory, auth, data plane, gates. I am **less confident** on: + +- The exact production behavior of the indexer "map-literal bug" workaround (regex in `use-received-endorsements.ts`) — I haven't probed against a real indexer tonight. +- Whether the `tests/` folder has any hidden harnesses (none surfaced, but I didn't grep exhaustively for `it(` / `describe(` patterns across `src/`). +- Whether the previously-removed `src/proxy.ts` (Next 16 proxy) was retired intentionally or is missing in error — AGENTS.md still documents it. + +Phase 1 will list these as targeted reviewer questions. diff --git a/docs/overnight-2026-05-18/01-review-plan.md b/docs/overnight-2026-05-18/01-review-plan.md new file mode 100644 index 00000000..a9d4c7f7 --- /dev/null +++ b/docs/overnight-2026-05-18/01-review-plan.md @@ -0,0 +1,126 @@ +# 01 — Review plan + +**Time budget for tonight:** ~8 hours wall clock. Phase 0 spent ~45 min. Remaining ~7h 15min. +- Phase 1 (this doc): 20 min. +- Phase 2 (diagnostic): 90 min — parallel reviewer agents. +- Phase 3 (triage and plan): 30 min. +- Phase 4 (implement + verify): 4h 30min. +- Phase 6 (final re-review): 60 min. +- Phase 7 (Draft PR, make checks green): 30 min. + +If diagnostic comes in fast I will spend it on implementation; if findings are heavier than expected I will spend it cutting MUST FIX scope rather than slipping implementation time. + +--- + +## What I am reviewing + +The full 79-commit delta of `feat/positioning-redesign` vs. `staging`. Inside that, the highest-risk surface (from orientation §10): + +- The five new API routes added on this branch (`/api/geocode`, `/api/groups/[groupDid]/{activity,follow,location,handle}`, plus extensions to existing routes). +- The dual-path write routing (`targetDid !== ownDid → CGS BFF` vs. `XRPC proxy`) repeated across six lib files. +- The four largest UI files (page.tsx, profile-endorsements, profile-overview, profile-sidebar, activity-detail). +- The TipTap leaflet editor + linearDocument conversion (URL/embed parsing, sanitization). +- Social graph sync write path (`use-social-graph-sync.ts`, `sync-social-graph-section.tsx`). +- The two known lint-baseline errors (use-user-indexer-activities, use-social-graph-sync) — they signal real correctness issues that the React 19 lint rules caught. +- The Next.js advisory (16.2.3 → 16.2.6 patch). +- The `.env.local.example` drift (STADIA, INDEXER, INDEXER_DID undeclared). +- CSP headers — newly extended for YouTube/Vimeo iframes; verify no regression in `connect-src`/`img-src`/`script-src` for the leaflet upload + geocode flows. + +--- + +## Lenses, with rationale + +I will apply six lenses. The first five run in **parallel** as five reviewer agents in Phase 2; the sixth is a thinner sequential pass that I run myself. + +### 1. Security (parallel agent A) — must apply + +Justification specific to this codebase: +- The branch adds new mutating routes (`/api/groups/[groupDid]/{activity,follow,location}`) and a new `/api/geocode` route. AGENTS.md §17 lists 11 mandatory server-side rules (CSRF first, HMAC verify, session-fixation defense, Redis try/catch, input double-sanitize, 5xx error sanitization, repo-ownership enforcement on writes, collection allowlist, blob limits, service-auth token scoping). Every new route must obey them; the lens checks coverage. +- The TipTap editor accepts user-supplied URLs and embed sources. AGENTS.md pitfall #11 explicitly calls out `javascript:` URLs as a one-click XSS — verify scheme allowlist on all `` and embed-iframe `src` assignments in the new leaflet code. +- CSP frame-src was extended to `https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com`. Verify the rest of CSP wasn't relaxed in the process; verify `connect-src` covers the new `/api/geocode` upstream (Nominatim, Stadia, or whatever it actually calls). +- The Next.js 16.2.3 → 16.2.6 advisory covers a middleware bypass, cache poisoning, and an XSS in CSP-nonce App Router. We use App Router, we don't appear to use CSP nonces (verify), but the cache-poisoning advisory matters because we cache nothing explicitly — also verify. + +### 2. Correctness & robustness (parallel agent B) — must apply + +Justification specific to this codebase: +- Hand-written type guards everywhere (no Zod). The orientation surfaced specific narrowing sites: `extractAwardSubjectDid`, `resolveActivityImageUrl`, location parsing. Are any of the guards in the *new* code missing a typeof check, ignoring a union arm, or trusting a server shape it shouldn't? +- The two known lint-baseline errors (`use-user-indexer-activities.ts:188`, `use-social-graph-sync.ts:77`) are red ESLint flags from the React 19 ruleset. These are not nits; they signal real bugs (ref read during render, lost memoization). Both have to be diagnosed. +- Pagination + safety caps in `follow.ts:148` and `useReceivedEndorsements:101` hardcode 10K. If a user crosses that, the UI silently truncates. Is the truncation flagged anywhere? +- Concurrent-edit windows: `mergeProfile()` in `profile.ts` is read-modify-write without CID swap (acknowledged in PR #63 body as "race window is small and acceptable for v1"). Are there other write paths added on this branch with the same window that are not similarly acknowledged? +- AbortController usage in the new hooks (use-social-graph-sync, use-cert-projects, use-project-items, use-org-marker, use-followers, use-following, use-rights) — does every fetch in an effect actually accept and check `signal.aborted` before `setState`? + +### 3. Architecture & coupling (parallel agent C) — must apply + +Justification specific to this codebase: +- The **`targetDid !== ownDid` dual-path write** is the same six-line block repeated in `badges.ts:28`, `cert.ts:28`, `follow.ts:69`, `location.ts:323`, `profile.ts:74` (approximate), and `org-marker.ts:?`. AGENTS.md pitfall #20 references one variant of it. Is a shared helper worth introducing tonight (scope: yes if ≤80 lines + 6 file touches; no if it ripples into hook tests/snapshots)? +- The **profile orchestration page** (`src/app/profile/[handle]/page.tsx`, 1145 lines) is composing too many subsystems. Tonight is not the night for a structural refactor, but is there a 50-line extraction (e.g., tab-resolver, hash-router) that genuinely reduces complexity? +- The **`use-received-endorsements.ts` N+1** (one indexer query + N PDS queries for definitions) is documented as a known limitation in the orientation. Is there a viable batch-fetch alternative *without* changing the indexer schema? + +### 4. Reuse & consistency (parallel agent D) — must apply + +Justification specific to this codebase: +- Lens (3) looks at deep coupling; this lens looks at shallow duplication and inconsistency. +- 18 plain-CSS files; per-feature naming. Are there two CSS rules doing the same thing under different selectors (e.g., the same border-radius value hard-coded instead of the `--radius-modal` token; the same paddings; AGENTS.md pitfall #16 explicitly warns about chunky-modal regressions)? +- Modal class adherence (AGENTS.md §11.383) — every modal needs both `signin-modal` and `app-modal` classes. The new `link-dialog.tsx`, `embed-dialog.tsx`, `long-description-modal.tsx`, `endorse-people-modal.tsx`, `endorse-reason-modal.tsx` are candidates to check. +- `smart-link.tsx` exists as a shared primitive with URL-scheme validation. Are user-controlled URLs in the new code rendering through `` or via raw ``? Pitfall #11 again. +- Optimistic-state pattern (AGENTS.md §15a, pitfall #17) — clear via parent-value-caught-up effect, not in `finally`. Does the social graph sync follow this? Do follower/following toggles? + +### 5. API contract & operations (parallel agent E) — must apply + +Justification specific to this codebase: +- The new API routes (`/api/geocode`, `/api/groups/[groupDid]/{activity,follow,location}`) — do they follow the AGENTS.md §24 checklist? (method-appropriate handler, CSRF first, auth second, body shape validation, sanitize input, allowlists, try/catch JSON parse, sanitize 5xx errors, log with route-tagged prefix, return-shape consistency). +- `.env.local.example` drift: `NEXT_PUBLIC_STADIA_API_KEY`, `NEXT_PUBLIC_INDEXER_URL`, `INDEXER_URL`, `INDEXER_DID` referenced but undeclared. Is each load-bearing for a code path? If yes, declare. If a route silently fails when the env is missing, document the failure mode. +- Logging consistency — does every new route use `console.error("[Route] …", err)` with a route-tagged prefix per AGENTS.md §25? +- Error contracts — every successful response is either `{ success: true }` or domain data; every error is `{ error: string }`. Are the new routes shape-consistent? + +### 6. Performance & accessibility (my own sequential pass, agent F) — apply lighter + +Justification specific to this codebase: +- I can't load-test or profile tonight. But I can flag obvious wins: avoidable N+1s in the new hooks; heavy synchronous parsing in render paths; un-memoized callbacks across the largest components; missing `aria-*` on the new modals. +- AGENTS.md §25 has firm a11y conventions (Input/Textarea via `useId` for label binding, modals use `useFocusTrap`, dropdowns have `aria-haspopup`+`aria-expanded`, icon-only buttons need `aria-label` or `title`). I will check every modal added on this branch. +- I keep this lens lighter so I don't manufacture findings I cannot validate. + +### Lenses I am **not** running tonight + +- **Heavy UX / visual design** — I have no browser; the redesign decisions were already operator-driven. The brief tonight is code-quality, not product judgment. +- **Test coverage** — there are no tests in this repo. Introducing a test framework would blow the scope ceiling (Vitest + jsdom + RTL + initial fixtures + CI wiring + first non-trivial suite). I will note "tests are absent" as a deferred operator decision, not implement them tonight. +- **Documentation** — AGENTS.md is already extremely thorough. I'll only flag if new code *contradicts* documented conventions, not if new code is undocumented (the project's bias is to document via AGENTS.md, and that update belongs with the operator). +- **Bundle size / Core Web Vitals deep analysis** — out of scope without metrics infra running locally. +- **GraphQL schema review of the indexer** — that's a different repo entirely. +- **Wallet / EIP-712 attestation flow** — not on the changed surface this branch. + +--- + +## Order of operation + +1. **Diagnostic, parallel (Phase 2)** — five reviewer agents (Security, Correctness, Architecture, Reuse/Consistency, API/Operations) launched together. They read independently and produce a structured findings list per lens. +2. **Diagnostic, sequential (Phase 2, my pass)** — Performance/a11y review, informed by what the architecture lens surfaces. +3. **Consolidate (Phase 2 end)** — I merge the findings into `02-findings.md`, dedupe, recalibrate severity, sort. +4. **Triage (Phase 3)** — MUST FIX / IF TIME / WON'T FIX, with full deep-flow plans (with alternatives) for each MUST FIX. +5. **Implement (Phase 4)** — atomic commits, lowest-risk first, independent before dependent, shared-surface last. Run `tsc + lint + build` after every commit. Mini-re-review every 3–5 commits. +6. **Final re-review (Phase 6)** — fresh framing over the full diff. Update the score on the standard five dimensions. +7. **Draft PR (Phase 7)** — open into `staging`, write the body, fix CI until green. + +--- + +## Stopping rule + +Stop and freeze the diff when **any** of these is true (whichever first): + +- All MUST FIX items are implemented and have passed at least one re-review with no new criticals. +- Implementation time budget (~4h 30min) is spent. Any in-progress non-atomic change gets reverted to a clean state; the branch must end the night green. +- A full review pass produces only nits — diminishing returns; move to final re-review. +- I hit something I cannot resolve without operator input. Document it as a deferred decision and move on; do not push through a guess. + +I will **not** start a new MUST FIX item if I cannot complete and verify it before the budget closes. Better to leave it deferred than to leave the branch half-broken. + +--- + +## Calibration: what counts as each severity tonight + +- **Critical** — actively exploitable security issue, data corruption, write to wrong repo, auth bypass, or anything that would break a user's PDS if shipped. +- **High** — real bug or hardening gap that lands in production today; user-facing failure mode that isn't covered by an error path; pre-existing lint error in committed code; security rule violation per AGENTS.md §17. +- **Medium** — worth fixing, won't hurt to defer; correctness improvement; consistency violation; missing input validation that's defended at another layer. +- **Low / nit** — style, naming, comment, micro-perf. **I will not implement these tonight** unless they ride along with a larger change in the same file. + +I will be honest about which lenses produced nothing. The brief explicitly accepts that as a valid outcome. diff --git a/docs/overnight-2026-05-18/02-findings-lens-6-perf-a11y.md b/docs/overnight-2026-05-18/02-findings-lens-6-perf-a11y.md new file mode 100644 index 00000000..ded38051 --- /dev/null +++ b/docs/overnight-2026-05-18/02-findings-lens-6-perf-a11y.md @@ -0,0 +1,100 @@ +# 02 — Findings (Lens 6: Performance + Accessibility) + +This is the thin sequential lens I run myself (per `01-review-plan.md`). It complements the five parallel reviewer agents (Security, Correctness, Architecture, Reuse/Consistency, API/Operations). My deliberate scope: things I can validate from code only, without a browser, profiler, or screen reader. + +## P-1: `useFocusTrap` is **not** required on the new `` modals (non-finding) + +- **Lens:** Accessibility +- **Location:** `src/components/leaflet/{embed-dialog,link-dialog,long-description-modal}.tsx`, `src/components/profile/{endorse-people-modal,endorse-reason-modal}.tsx`, `src/components/profile/endorsement-lists.tsx`, `src/components/settings/sync-social-graph-section.tsx` +- **Severity:** none (would otherwise be high) +- **Problem:** None — flagging in case a future reviewer worries about it. +- **Evidence:** The new modals on this branch use the native `` element and call `dialog.showModal()` in an effect. `showModal()` provides browser-native focus trap, background inertness, and Escape-close. The pre-existing `useFocusTrap` is only needed for `
`-based modals (sign-in, feedback, membership-sync, add-org), which are unchanged. +- **Action:** None. + +## P-2: Profile page composes ~32 stateful hooks at top level + +- **Lens:** Performance / readability +- **Location:** `src/app/profile/[handle]/page.tsx` (top of file, declarations) +- **Severity:** low +- **Problem:** The page declares ~32 useState/useEffect/useMemo/useCallback/useRef at the top. Each tab unmounts/remounts on switch (assuming current tab gating) but the state-management surface is wide. Any single setState in the page re-renders all 32 closures. +- **Evidence:** `grep -cE "useState|useEffect|useMemo|useCallback|useRef" src/app/profile/[handle]/page.tsx` → 32. +- **Proposed direction:** Not tonight. Would benefit from extracting per-tab orchestration into colocated hooks, but that's a large architecture-lens decision. Architecture agent owns the call. +- **Effort:** M-L. **Risk:** medium (load-bearing file). **Reversibility:** hard once split. +- **Action:** Deferred — defer to architecture lens. + +## P-3: `mergeMaps` is recomputed each render when refs flip — the "useMemo would do this" path + +- **Lens:** Performance +- **Location:** `src/hooks/use-user-indexer-activities.ts:178-202` (function `useMergedDidsMap`) +- **Severity:** medium (paired with C-? from correctness agent — the same code is a lint error site) +- **Problem:** Same code as the known lint error. The custom "useState + useRef compare-and-update during render" pattern reimplements `useMemo` with referential dependency tracking. The `useMemo` version is shorter, lint-clean, semantically identical, and React Compiler-friendly. +- **Evidence:** Current: + ```ts + const [merged, setMerged] = useState>(() => mergeMaps(a, b)) + const lastRef = useRef<{ a: typeof a; b: typeof b } | null>({ a, b }) + if (lastRef.current?.a !== a || lastRef.current?.b !== b) { + lastRef.current = { a, b } + setMerged(mergeMaps(a, b)) + } + return merged + ``` + Cleaner: + ```ts + return useMemo(() => mergeMaps(a, b), [a, b]) + ``` +- **Proposed direction:** Replace with `useMemo`. Resolves all 5 lint baseline errors in this file at the same time. Behavior identical (both recompute when either reference flips). +- **Effort:** S. **Risk:** low. **Reversibility:** easy. +- **Action:** MUST FIX (also covered by Correctness lens; this is a duplicate intentional surfacing so the implementation phase remembers the cross-lens consensus). + +## P-4: Dead ternary in `useSocialGraphSync` `refetch` + +- **Lens:** Performance / dead code (the actual lint error) +- **Location:** `src/hooks/use-social-graph-sync.ts:77-79` +- **Severity:** medium +- **Problem:** `refetch` reads `bluesky` only to feed a ternary whose two branches are identical (`Promise.resolve()`). The closure capture of `bluesky` is what trips the React Compiler memoization-preservation check at line 77:31. Removing the dead branch unblocks compiler memoization and removes the (correct) lint error. +- **Evidence:** + ```ts + const refetch = useCallback(async () => { + await Promise.all([certified.refetch(), bluesky ? Promise.resolve() : Promise.resolve()]) + }, [certified]) + ``` +- **Proposed direction:** Drop the ternary and the `bluesky` capture: + ```ts + const refetch = useCallback(() => certified.refetch(), [certified]) + ``` + Comment explaining why `bluesky` has no refetch is already in place (lines 80-83); preserve it. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. +- **Action:** MUST FIX (also surfaced by Correctness; duplicate intentional). + +## P-5: CSP `script-src 'unsafe-inline'` — known and pre-existing (non-finding for this branch) + +- **Lens:** Performance / security (light) +- **Location:** `next.config.ts:36-44` +- **Severity:** none for this branch +- **Problem:** None — the branch's CSP change touches only `frame-src` (adds YouTube/Vimeo). `'unsafe-inline'` in `script-src` and `style-src` is unchanged and pre-existing. Not in scope tonight. +- **Action:** None. + +## P-6: `` use is exactly one tag, with `alt=""` for decorative + +- **Lens:** Accessibility / performance (Next/Image vs raw img) +- **Location:** `src/components/profile/profile-projects.tsx:277` +- **Severity:** none (informational) +- **Problem:** The codebase uses `next/image` for all non-decorative images. The single `` is decorative (`alt=""`) with `loading="lazy"` and an error fallback. That's correct. +- **Action:** None. + +## P-7: Hardcoded 10 000-record cap in `listFollowing` truncates silently + +- **Lens:** Correctness / UX edge case +- **Location:** `src/lib/atproto/follow.ts:~148` (`listFollowing` safety cap) +- **Severity:** low (most users won't approach this; the value is generous) +- **Problem:** When the cap is hit, the function returns the truncated list with no indicator. The UI doesn't render a "showing first 10k" hint. Bluesky users with high follow counts could be affected. +- **Evidence:** The cap is hardcoded; no surfaced flag in the returned shape. +- **Proposed direction:** Make the function return `{ subjects, isTruncated: boolean }` and surface the flag in `useFollowing`. Display a small notice in the consumer UI when truncated. Not tonight unless a free-rider. +- **Effort:** M (touches hook return shape and consumers). **Risk:** med. **Reversibility:** easy. +- **Action:** Defer unless free rider. + +## Lens summary + +- 0 critical, 0 high, 2 medium (P-3, P-4 — both reinforce Correctness findings), 1 low (P-7), 3 non-findings (P-1, P-5, P-6). +- Performance and accessibility on the new code are **well-handled**. The native `` choice is a clean answer to the focus-trap requirement. Memoization is used appropriately in the heavy components. The only real issues are the two lint baseline sites, both of which are also Correctness findings and both of which are S-effort, low-risk fixes. +- I will not implement P-2 (page.tsx split) or P-7 (truncation surfacing) tonight unless a stronger architectural finding pulls them in. diff --git a/docs/overnight-2026-05-18/02-findings.md b/docs/overnight-2026-05-18/02-findings.md new file mode 100644 index 00000000..1c1db7dc --- /dev/null +++ b/docs/overnight-2026-05-18/02-findings.md @@ -0,0 +1,356 @@ +# 02 — Findings (consolidated) + +This is the consolidated diagnostic from Phase 2. Six lenses were run (five parallel reviewer agents, one sequential pass by me). Per-lens raw outputs live in `02-findings-lens-1-security.md`, `02-findings-lens-2-correctness.md`, `02-findings-lens-3-architecture.md`, `02-findings-lens-4-reuse.md`, `02-findings-lens-5-api-ops.md`, and `02-findings-lens-6-perf-a11y.md` (lens 6 is the only one I wrote myself — the others are reviewer-agent transcripts captured per their summary blocks). + +**Document is frozen at end of Phase 2.** New discoveries during implementation will go in `02b-late-findings.md`. + +--- + +## Cross-lens consensus (the items multiple reviewers independently surfaced) + +These are the items where two or more lenses converged on the same problem. They are the strongest signals. + +| Issue | Lenses | Severity (consensus) | +|---|---|---| +| **Leaflet renderer + editor: user-controlled URLs rendered without scheme allowlist** (`javascript:` XSS class). Existing helper `safeHttpUrl` not called. | Security S-1, S-2, S-3, S-4; Correctness C-2; Reuse R-1, R-2 | **CRITICAL** | +| **`useMergedDidsMap` reinvents `useMemo` with refs (5 lint baseline errors)** | Correctness C-3; Lens-6 P-3 | High | +| **`useSocialGraphSync.refetch` broken memoization + dead ternary (1 lint baseline error)** | Correctness C-4; Lens-6 P-4 | Medium | +| **`/api/geocode` route is unauth + no rate limit + leaks Nominatim quota** | API/Ops O-1, O-2, O-3; Security S-6, S-7 | High | +| **`.env.local.example` drift — INDEXER_URL, INDEXER_DID, NEXT_PUBLIC_INDEXER_URL, NEXT_PUBLIC_STADIA_API_KEY missing** | API/Ops O-7; Orientation §7 | High | +| **Indexer proxy silently falls back to dev URL in production** | API/Ops O-19 | High | +| **`/api/groups/[groupDid]/activity` lacks field allowlist (mass-assignment regression vs. the April audit's CS-005)** | API/Ops O-5; Security S-5 | Medium | +| **`extractRouteError` returns generic 4xx messages — contradicts AGENTS.md §17 #7** | API/Ops O-12 | Medium | +| **`extractRouteError` trusts arbitrary status integer from upstream errors** | API/Ops O-13 | Medium | +| **Dual-path write pattern duplicated across 5 lib files with subtle divergence (error extraction, return contract, parameter shape)** | Architecture A-1, A-2; Reuse R-8 | Medium | +| **Bare `console.error` in three group routes — `extractRouteError` already logs via `logSafe`** | API/Ops O-6 | Medium | +| **`--danger` CSS var is undeclared but referenced 16x with `#d44` fallback (should be `--color-error`)** | Reuse R-4 | Medium | +| **Next.js 16.2.3 → 16.2.6 patch (advisory chain)** | Security S-10; Orientation §6 | Low | + +--- + +## Findings master list + +Below: every finding from every lens, deduplicated where they covered the same site, sorted by severity. + +### Critical + +#### F-1 — Leaflet XSS class: user-controlled URLs rendered without scheme allowlist +*Cross-lens: S-1, S-2, S-3, S-4, C-2, R-1, R-2.* + +- **Lens:** Security (primary), Correctness, Reuse/Consistency +- **Locations:** + - `src/components/leaflet/leaflet-document.tsx:217-224` — iframe non-allowlisted host fallback renders `` unfiltered. + - `src/components/leaflet/leaflet-document.tsx:361-371` — facet link rendering: `` with no scheme check. + - `src/components/leaflet/nodes/leaflet-iframe-node.tsx:58-66` — same iframe fallback pattern as above. + - `src/components/leaflet/leaflet-editor.tsx:239-251` — `editor.chain().insertContent(...)` bypasses TipTap's link `isAllowedUri` validator (only `setLink`/`toggleLink` runs it). + - `src/components/leaflet/link-dialog.tsx:79` — dialog returns the raw URL with no validation; `type="url"` is not real validation. + - `src/lib/leaflet/from-tiptap.ts:225-237` (`marksToFeatures`) — writes link facets verbatim to the PDS (defense-in-depth on the write boundary). + - `src/lib/leaflet/to-tiptap.ts:266-271` (`featureToMark`) — hydrates link marks from foreign records with no validation (defense-in-depth on read). +- **Severity:** **Critical** (multiple reviewers; the renderer is mounted on profile hub, cert detail, profile overview, long-description modal — all foreign-content surfaces). +- **Problem:** AGENTS.md §22 pitfall #11 explicitly calls out this exact attack: `javascript:alert(1)` becomes one-click XSS. The pre-existing `safeHttpUrl()` helper at `src/lib/utils/safe-url.ts` was written for this purpose and is not called from any of these sites. +- **Evidence:** Any atproto account holder can author a `pub.leaflet.pages.linearDocument` record on their own PDS with a `pub.leaflet.richtext.facet#link` carrying `{ uri: "javascript:fetch('/api/auth/session')..." }`. Every signed-in viewer of that profile/cert runs the script in certified.app's origin, with full access to the indexer proxy, group BFF, and write paths. The editor's no-selection link insertion path is the second route in: even without a malicious foreign record, a careless paste by the user lands the same payload onto their own PDS, where every later viewer is attacked. +- **Proposed direction:** Single point of fix: call `safeHttpUrl(uri)` at the renderer (both fallback sites + the facet branch) and in the writer/reader (from-tiptap + to-tiptap) and in the link dialog's submit handler. When the helper returns null, render plain text (no `` wrapper) or substitute `"#"`. Five call sites, ~6-10 lines each. +- **Effort:** S. **Risk:** low (tightening only). **Reversibility:** trivial. + +### High + +#### F-2 — `useMergedDidsMap` reads + writes a ref during render (5 lint baseline errors) +*Cross-lens: C-3, P-3.* + +- **Lens:** Correctness +- **Location:** `src/hooks/use-user-indexer-activities.ts:178-202` +- **Severity:** High (correctness bug under StrictMode + 5 of the 6 lint baseline errors) +- **Problem:** The hook reinvents `useMemo` with `useRef` + `setState` during render. Under React StrictMode (or any concurrent render that is discarded and replayed), the ref mutation persists across discarded renders — the second pass sees the cached `{a,b}` and skips the `setMerged`, leaving stale state. Also schedules an extra render per dependency flip. +- **Evidence:** Code: + ```ts + const lastRef = useRef<{ a: typeof a; b: typeof b } | null>({ a, b }) + if (lastRef.current?.a !== a || lastRef.current?.b !== b) { + lastRef.current = { a, b } + setMerged(mergeMaps(a, b)) + } + ``` +- **Proposed direction:** Replace the entire helper body with `return useMemo(() => mergeMaps(a, b), [a, b])`. The "stable reference unless inputs change" semantic is exactly what `useMemo` provides. Resolves all 5 lint errors at once. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-3 — `/api/geocode` is open to the internet with no auth or rate-limit +*Cross-lens: O-1, S-6.* + +- **Lens:** API/Operations, Security +- **Location:** `src/app/api/geocode/route.ts:62-166` (handler), `:10-18` (intent comments) +- **Severity:** High +- **Problem:** GET handler (no CSRF gate by design), no `getSessionDid()` check, no origin restriction, no rate limit. Anonymous traffic on the open internet can call it and force certified.app's outbound IP to make Nominatim queries. Nominatim's policy is "absolute maximum 1 req/sec"; sustained abuse gets the egress IP blocked for legitimate users. The `s-maxage=86400` edge cache helps for repeated queries but does nothing for unique `q=` scans. Also costs Vercel function execution time per invocation. +- **Evidence:** Geocode is only used by the location picker on auth-gated edit screens. Gating on session would be invisible to legitimate users. +- **Proposed direction:** Add a `getSessionDid()` early-return at the top of the handler. If the route must remain public, IP-based rate-limit via Upstash. Option A (session gate) is the smaller diff and matches actual usage. +- **Effort:** S. **Risk:** low (geocode UI is auth-gated today). **Reversibility:** trivial. + +#### F-4 — `.env.local.example` missing four load-bearing env vars (silent dev-indexer fallback in prod) +*Cross-lens: O-7, O-19 (related).* + +- **Lens:** API/Operations +- **Location:** `.env.local.example` (declarations); `src/app/api/indexer/route.ts:21-23`, `src/app/api/notifications/route.ts:26,31`, `src/lib/map/tiles.ts:38` (read sites) +- **Severity:** High +- **Problem:** Four env vars read by production code are absent from the example: + - **`INDEXER_URL`** — falls back to `NEXT_PUBLIC_INDEXER_URL`, then to a hardcoded `https://magic-indexer-dev.up.railway.app/graphql` *dev* instance. A production deploy with this var unset silently routes every feed/notifications query at the dev indexer. + - **`NEXT_PUBLIC_INDEXER_URL`** — legacy fallback, still read. + - **`INDEXER_DID`** — required for the notifications JWT `aud` claim. The notifications route handles this case correctly (module-load warn + 503 on request); the indexer route does not. + - **`NEXT_PUBLIC_STADIA_API_KEY`** — optional with documented fallback (Carto) and warn-once log; least urgent. +- **Evidence:** `grep -r process.env.INDEXER_URL src/` → 4 sites; `grep -r process.env.INDEXER_DID src/` → 2 sites; not in `.env.local.example`. Module-level `console.warn` only exists on the notifications side (lines 34-37 of notifications/route.ts). +- **Proposed direction:** (a) Declare all four vars in `.env.local.example` with comments explaining failure modes. (b) Add a module-load `console.warn` to the indexer route mirroring notifications' pattern: in production, no `INDEXER_URL` → loud warning at boot. Optionally: remove the dev-URL fallback in production builds. +- **Effort:** S (docs + 5-line warn block). **Risk:** none. **Reversibility:** trivial. + +#### F-5 — LeafletEditor resets cursor on every keystroke +*Single-lens: C-1.* + +- **Lens:** Correctness +- **Location:** `src/components/leaflet/leaflet-editor.tsx:147-155` (value-sync effect) +- **Severity:** High (if this actually bites it's a constant data-loss UX problem in the rich text editor) +- **Problem:** The "sync external value" effect re-runs on every parent state change. Because the parent stores the user's typed content in `drafts.description` (e.g. `activity-detail.tsx:617-620`), every keystroke yields a new `value` reference whose `next` (`toInitialDoc(value)`) differs from the prior `lastExternalRef.current`. Result: each keystroke fires `tr.replaceWith(0, doc.content.size, …)`, which destroys the selection / cursor position. `emitUpdate:false` suppresses the change event but not the doc replacement. +- **Evidence:** First render: `lastExternalRef.current = initial = emptyDoc`. User types "h" → `onUpdate` → parent sets `drafts.description = linearDoc("h")`. Re-render: effect computes `next = tipDocOf("h")`, `shallowEqual(tipDocOf("h"), emptyDoc) === false` → setContent runs → cursor reset. +- **Proposed direction:** Compare `next` against the editor's *current* JSON (`editor.getJSON()`) — not against `lastExternalRef.current`. Or update `lastExternalRef.current` inside `onUpdate` so a self-originated change is recorded before the parent's setState reflects back. Implementer should manually exercise the editor (type a sentence; verify cursor stays put) since there's no test infrastructure. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-6 — Indexer proxy passes arbitrary GraphQL through with no operation allowlist (write-amplification surface) +*Single-lens: O-9.* + +- **Lens:** API/Operations +- **Location:** `src/app/api/indexer/route.ts:51-137` +- **Severity:** High (architectural; the smaller defense fix is low-risk and small) +- **Problem:** The proxy passes the entire client body verbatim to the upstream GraphQL endpoint. CSRF is checked (same-origin only), no auth gate by design (feed is publicly readable), but any same-origin context (including any XSS payload — F-1) can make arbitrary GraphQL calls including mutations through our BFF. The notifications route does this right (server-held query strings + operation-name allowlist + variable scrubbing); the indexer route does not. +- **Evidence:** `route.ts:104-111` — `fetch(upstream, { body })` with raw client text. Compare `notifications/route.ts:53-82`. +- **Proposed direction:** Tonight, the bare-minimum fix: reject any request whose JSON-parsed body has `"mutation"` at the start of the trimmed `query` string. Full restructure (move queries server-side) is the right answer but blows the scope ceiling for a single commit — defer to operator. +- **Effort:** S (minimum) / M (full restructure). **Risk:** low (minimum) / med (full). **Reversibility:** easy. + +### Medium + +#### F-7 — `useSocialGraphSync.refetch` rebuilds every render (1 lint baseline error) +*Cross-lens: C-4, P-4.* + +- **Lens:** Correctness +- **Location:** `src/hooks/use-social-graph-sync.ts:77-79` +- **Severity:** Medium (the React Compiler memoization-could-not-be-preserved baseline error) +- **Problem:** `useCallback(async () => { await Promise.all([certified.refetch(), bluesky ? Promise.resolve() : Promise.resolve()]) }, [certified])` — the dep is the whole `certified` object (always-fresh literal from `useFollowing` — see C-4 in raw transcript), and the `bluesky ? ... : ...` ternary is dead (both branches identical). Identity changes every render; downstream memoization broken. +- **Proposed direction:** Replace with `useCallback(() => certified.refetch(), [certified.refetch])`. Drop the dead ternary. Preserve the explanatory comment. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-8 — `/api/groups/[groupDid]/activity` PUT lacks record-field allowlist (regression vs. the April audit's CS-005) +*Cross-lens: O-5, S-5.* + +- **Lens:** Security, API/Operations +- **Location:** `src/app/api/groups/[groupDid]/activity/route.ts:58-68` +- **Severity:** Medium +- **Problem:** Sibling group BFF routes (`/profile`, `/metadata`, `/location`) all field-allowlist via `pickAllowedFields` or a hand-rolled set. The activity route alone does `{ ...rawRecord, $type: ACTIVITY_COLLECTION }` — every property of the request body is forwarded verbatim to the group's PDS. AUDIT_REPORT F-013/F-014 documented this exact pattern as the "mass assignment in group routes" root cause and CS-005 fixed it for profile/metadata. The activity route was added later and drifted. +- **Proposed direction:** Define `ACTIVITY_FIELDS` mirroring the lexicon (`title`, `shortDescription`, `description`, `image`, `workScope`, `workTimeframe`, `subjects`, `contributors`, `claimDelegate`, `verifiedAt`, `createdAt`, etc.) and route through `pickAllowedFields`. Verify against the cert-edit form's actual write payload before tightening too far. +- **Effort:** S. **Risk:** low (additive). **Reversibility:** trivial. + +#### F-9 — `extractRouteError` returns generic message for 4xx (contradicts AGENTS.md §17 #7) +*Cross-lens: O-12.* + +- **Lens:** API/Operations +- **Location:** `src/lib/utils/api.ts:36-44` +- **Severity:** Medium (UX-shaped) +- **Problem:** AGENTS.md §17 #7 and §24 #8 both say "4xx errors *can* echo upstream messages — those are usually validation a user can act on." The helper returns generic messages for ALL statuses, including 4xx. Users see `"Bad request"` instead of `"Handle must be at least 3 characters"`. The XRPC proxy's `xrpcError()` already does this right (uses `rawMessage` for 4xx, generic for 5xx). +- **Proposed direction:** Make the helper echo `err.message` on 4xx after `redactSecrets`, keep generic for 5xx. Mirror the XRPC proxy's pattern. Touches one function; many routes silently benefit. +- **Effort:** S. **Risk:** low (the policy change is explicitly endorsed by the security rules). **Reversibility:** trivial. + +#### F-10 — `extractRouteError` trusts unbounded `status` integer from upstream errors +*Single-lens: O-13.* + +- **Lens:** API/Operations +- **Location:** `src/lib/utils/api.ts:23-34` +- **Severity:** Medium +- **Problem:** Pulls `e.status` or `e.statusCode` from `unknown` and returns it as-is. A malformed upstream response (or `status: 0`/`status: 700`) flows straight through as the HTTP status of our response. `NextResponse` accepts any integer; caches/browsers behave weirdly on non-standard codes. +- **Proposed direction:** Clamp to `200 ≤ s ≤ 599`; if outside, default to 500. Same shape would benefit `xrpcError` (`src/app/api/xrpc/[...method]/route.ts:99-100`) but that's a separate line. +- **Effort:** S. **Risk:** none. **Reversibility:** trivial. + +#### F-11 — Dual-path write routing duplicated across 5 lib files; shape divergence +*Cross-lens: A-1, A-2, R-8.* + +- **Lens:** Architecture, Reuse +- **Locations:** `src/lib/atproto/{cert,profile,location,follow}.ts`, `src/lib/groups/org-marker.ts` +- **Severity:** Medium +- **Problem:** The "target vs. own → CGS BFF vs. XRPC" branch is repeated 5 times with already-diverging behavior: error extraction (`extractError` vs inline JSON parse in `follow.ts`), return contracts (`{uri,cid}` vs `void`), parameter shapes (`opts?.targetDid` vs positional). Not a bug today; exactly the slope a single shared seam prevents. +- **Proposed direction:** Add a `writeToRepo` helper that takes an own/target DID pair, an XRPC operation descriptor, and a group-route descriptor. Each helper collapses to one call. ~5 files of edits + 1 new file. ~80-120 lines added, ~130-160 removed. Within scope ceiling. Recommended for tonight by the architecture agent as "the single highest-value architecture finding." +- **Effort:** M (~2-3h). **Risk:** low. **Reversibility:** high. + +#### F-12 — Bare `console.error` in 3 group routes leaks JWT/DPoP detail via `err.cause` +*Cross-lens: O-6.* + +- **Lens:** API/Operations, Security +- **Locations:** `src/app/api/groups/[groupDid]/profile/route.ts:99`, `upload-blob/route.ts:63`, `metadata/route.ts:55` +- **Severity:** Medium +- **Problem:** `logSafe` (from `src/lib/utils/log-safe.ts`) strips JWT/DPoP/Authorization detail that the atproto SDK attaches to `err.cause`. Three routes still use raw `console.error(label, err)`. The metadata route additionally calls `extractRouteError` below, so the bare line is duplicate+raw. +- **Proposed direction:** Replace bare `console.error` with `logSafe`. For routes that also call `extractRouteError` (which internally calls `logSafe`), drop the bare line entirely. +- **Effort:** S. **Risk:** none. **Reversibility:** trivial. + +#### F-13 — Object URL leak in cert inline-edit on save +*Single-lens: C-5.* + +- **Lens:** Correctness +- **Location:** `src/components/feed/activity-detail.tsx:312-315` and surrounding image lifecycle +- **Severity:** Medium +- **Problem:** After save, the pending preview URL is transferred to `localImageUrl` (`setLocalImageUrl(pendingImagePreviewUrl); setPendingImagePreviewUrl(null)`). Next time edit mode reopens with a new image pick, the prior `localImageUrl`'s object URL is never revoked — only `pendingImagePreviewUrl` is revoked on replace. No unmount cleanup either. +- **Proposed direction:** Revoke any prior `localImageUrl` before promoting pending → local on save. Add an unmount cleanup that revokes whichever URL is still set. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-14 — `useSession` doesn't clear handle/email on sign-out +*Single-lens: C-6.* + +- **Lens:** Correctness +- **Location:** `src/hooks/use-session.ts:68-74` +- **Severity:** Medium +- **Problem:** When `isAuthenticated` flips to false, the effect clears the module-level cache but never calls `setHandle(null)` / `setEmail(null)`. Long-lived components that mounted while signed in keep returning that user's handle/email after sign-out until they unmount. +- **Proposed direction:** Add `setHandle(null); setEmail(null); setError(null);` to the sign-out branch. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-15 — Ordered nested lists round-trip as bullet +*Single-lens: C-7.* + +- **Lens:** Correctness +- **Location:** `src/lib/leaflet/from-tiptap.ts:174-177` vs. `src/lib/leaflet/to-tiptap.ts:160-174` +- **Severity:** Medium (data-loss; visible to user on next edit) +- **Problem:** `from-tiptap` stores nested ordered lists in `children`; `to-tiptap` hydrates `children` as a `bulletList`. User creates nested ordered list → reopens as bullet. +- **Proposed direction:** On the writer side, switch between `children` and `orderedListChildren` based on the nested node's type. The reader's asymmetric handling is already correct. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-16 — `listFollowing` silent truncation at 10k cap +*Cross-lens: C-9, P-7.* + +- **Lens:** Correctness +- **Location:** `src/lib/atproto/follow.ts:148-185`; same pattern in `use-received-endorsements.ts:101-104`, `use-followers.ts:180`. +- **Severity:** Medium +- **Problem:** Pagination loop breaks at 10K; no `truncated` flag is returned. A user crossing 10K follows shows as "9999+", and the derived `subjects` Set becomes incomplete, causing `useSocialGraphSync`'s "do I already follow X?" check to return false-negatives and re-import duplicates. +- **Proposed direction:** Return `{ records, truncated: boolean }` from `listFollowing`. Propagate to hooks. Render an indicator in the consumer UI when truncated. ~25 LOC across 3-4 files. +- **Effort:** M (touches hook return shape and 2-3 consumer files). **Risk:** low. **Reversibility:** easy. + +#### F-17 — `importDids` write loop has no abort path +*Single-lens: C-11.* + +- **Lens:** Correctness +- **Location:** `src/hooks/use-social-graph-sync.ts:87-136` +- **Severity:** Medium +- **Problem:** Serial for-loop with `await createFollow(...)` per DID; no `AbortSignal` checked between iterations. User closes the modal mid-import → loop continues writing follows to the repo and finally `setIsWriting(false)` fires on an unmounted component; module-level cache keeps populating with records the user thought they cancelled. +- **Proposed direction:** Accept an `AbortSignal` in `importDids`; check `signal.aborted` between iterations. The hook caller wires it to modal-open state. +- **Effort:** S. **Risk:** low. **Reversibility:** trivial. + +#### F-18 — `mergeProfile`-style race window also present on cert inline edit (unacknowledged) +*Single-lens: C-19.* + +- **Lens:** Correctness +- **Location:** `src/components/feed/activity-detail.tsx:270-323`, `src/lib/atproto/cert.ts:20-57` +- **Severity:** Medium +- **Problem:** Same shape as `mergeProfile`'s acknowledged race (PR #63 body): cert edit reads value at mount, user spends minutes editing, save → `next = { ...effectiveValue, title, shortDescription, image, description }` → `putRecord(rkey)` overwrites whatever's on the PDS. Concurrent edits to `contributors`/`locations` get clobbered. No `swapRecord` precondition, no comment acknowledging the window. +- **Proposed direction:** Two tiers — tonight: add the same kind of acknowledgement comment as `mergeProfile`. Future: pass the CID we read at mount into a `swapRecord` precondition. +- **Effort:** S (comment) / M (swapRecord). **Risk:** low. **Reversibility:** easy. + +#### F-19 — `--danger` CSS variable undeclared, hard-coded `#d44` fallback used 16 times +*Single-lens: R-4.* + +- **Lens:** Reuse / Consistency +- **Locations:** `src/app/styles/feed.css:1901,1927,2031`; `src/app/styles/profile-endorsements.css` (10 occurrences); `src/app/styles/social-graph-sync.css:78,194,438` +- **Severity:** Medium (breaks dark-theme contract — `#d44` is fixed regardless of theme) +- **Problem:** `--danger` is not declared anywhere. `var(--danger, #d44)` always resolves to the hard-coded `#d44`. Meanwhile `--color-error: #ba1a1a` (light) / `#f87171` (dark) IS declared in `tokens.css:91,221` for exactly this purpose. AGENTS.md §11 rule 3: "Reuse the CSS variables; don't hard-code colors." +- **Proposed direction:** Replace 16 occurrences with `var(--color-error)` (no fallback needed since it's declared). +- **Effort:** S (sed-style). **Risk:** light visual change (`#d44` → `#ba1a1a` in light mode; dark mode gains response). Designer should confirm; arguably this is what was intended. +- **Reversibility:** Trivial. + +#### F-20 — `100vw` reintroduced in `leaflet.css` (AGENTS.md pitfall #13) +*Single-lens: R-7.* + +- **Lens:** Reuse / Consistency +- **Location:** `src/app/styles/leaflet.css:466` +- **Severity:** Medium (causes horizontal scroll when a vertical scrollbar is present) +- **Problem:** `.long-description-modal { max-width: min(720px, calc(100vw - 32px)); }`. AGENTS.md §11 rule 1 and pitfall #13 both forbid `100vw`. +- **Proposed direction:** `max-width: min(720px, calc(100% - 32px))` (native `` is body-positioned so `100%` resolves to viewport-ish without the scrollbar gotcha). +- **Effort:** S. **Risk:** none. **Reversibility:** trivial. + +#### F-21 — `createdAt` stripped on group follow writes (loses Bluesky import history) +*Single-lens: O-4.* + +- **Lens:** API/Operations +- **Location:** `src/app/api/groups/[groupDid]/follow/route.ts:55-59` +- **Severity:** Medium +- **Problem:** The route hardcodes `createdAt: new Date().toISOString()` regardless of body. For the social-graph sync flow, the original Bluesky follow time is the user's intent — losing it produces a chronologically-incorrect graph history. Also inconsistent with the personal-repo path which preserves whatever the client puts in the record. +- **Proposed direction:** Accept and validate an optional `createdAt` ISO-8601 string on the body; pass through. The sync flow can then send original timestamps. +- **Effort:** S. **Risk:** low (additive). **Reversibility:** trivial. + +### Low + +These are low-priority items. Per the brief, I implement them tonight only if they ride along with a larger change in the same file. + +| ID | Lens | Site | Summary | +|---|---|---|---| +| F-22 | API/Ops O-2 | `geocode/route.ts:92-97, 140-144` | Echoes upstream status in 502 body. Use `"Geocoding upstream unavailable"`. | +| F-23 | API/Ops O-3 | `geocode/route.ts:92-97, 140-144` | No `logSafe` on `!res.ok`. Add. | +| F-24 | Security S-7 | `geocode/route.ts:162-165` | Raw `console.error("[geocode] upstream error", err)` — should be `logSafe`. (Rides with F-23.) | +| F-25 | API/Ops O-8 | `src/lib/map/tiles.ts:44` | `NEXT_PUBLIC_STADIA_API_KEY` is bundle-public by design; document the Stadia referer-allowlist requirement in `.env.local.example`. (Rides with F-4.) | +| F-26 | API/Ops O-10 | `src/app/api/xrpc/[...method]/route.ts:29,39-42` | AGENTS.md §17 #9 still says "four `ALLOWED_WRITE_COLLECTIONS`"; now eleven. Update doc string. | +| F-27 | Security S-8 / API/Ops O-11 | All `groups/[groupDid]/*/route.ts` | Document in AGENTS.md that group BFF intentionally trusts CGS for role enforcement (or add app-tier check — defer to operator). | +| F-28 | Security S-9 | `geocode/route.ts:78-81` | `parseInt` allows `"3.7"` → `3`. Replace with `Number()` + `Number.isInteger`. | +| F-29 | Security S-10 / Lens-6 P-5 | `package.json` | Next.js 16.2.3 has high-severity advisory chain; 16.2.6 patches. Within-minor bump. | +| F-30 | Correctness C-12 | `src/components/profile/profile-endorsements.tsx:1023-1049` | `useAuthorNamesMap` permanently caches negative results on transient failure. Cache.delete on error. | +| F-31 | Correctness C-13 | `src/lib/atproto/badges.ts:151-171` | `extractAwardSubjectDid` returns junk for malformed at-uri. Validate with `isValidDid`. | +| F-32 | Correctness C-15 | `src/lib/leaflet/from-tiptap.ts:67-84` | Empty-content blocks dropped on save (paragraph-of-hard-breaks). | +| F-33 | Correctness C-16 | `src/hooks/use-session.ts:85-96` | `fetchSession` setState-after-unmount on `then/catch`. Add `cancelled` sentinel. | +| F-34 | Reuse R-15 | `src/hooks/use-social-graph-sync.ts:102-132` | `isWriting=true` leaks on `refetch` failure. Wrap in try/finally. | +| F-35 | Reuse R-6 | `src/app/styles/cert-detail.css:75,116` | `.cert-detail__image` defined twice. Merge. | +| F-36 | Reuse R-5 | `src/app/styles/profile.css` vs `profile-projects.css`/`profile-groups.css` | Stale BEM blocks in `profile.css`. Delete. | +| F-37 | Reuse R-14 | `profile-inline-edit.css:27,42,269,283`; `cert-detail.css:135,150` | `rgba(17,17,17,…)` hard-coded 6x. Introduce `--overlay-image{,-strong}` tokens. | +| F-38 | Reuse R-11 | 4 new modals/editor sites | Inline error rendering bypasses ``. | +| F-39 | API/Ops O-20 | `src/lib/groups/proxy-agent.ts:164-169` | `getAuthenticatedAgent()` silent oauth-restore failure. Add `logSafe`. | +| F-40 | Reuse R-9 | `src/hooks/use-org-marker.ts:143`; `use-private-memberships.ts:127` | `refresh` → rename to `refetch` for consistency. | +| F-41 | Reuse R-10 | `src/hooks/use-cert-projects.ts`, `use-rights.ts`, `use-project-items.ts` | Missing `error`/`refetch` on return; add or comment intent. | +| F-42 | Reuse R-13 | `leaflet.css:470` | `border-radius: 14px` hardcoded. Token or use `var(--radius)`. | +| F-43 | API/Ops O-14 | `src/app/api/resolve-did/route.ts:163-180` | No handle length cap / shape regex. | +| F-44 | API/Ops O-16 | `src/app/api/groups/[groupDid]/handle/route.ts:36-43` | No handle shape regex (just length). | +| F-45 | Correctness C-14 | `src/lib/atproto/location.ts:60-63` | `parseLocationCoords` accepts `"45.1,9.2,30"` silently. | +| F-46 | Correctness C-8 | `src/lib/atproto/badges.ts:225-277` | Cross-tab race in `ensureEndorsementDefinition` can yield duplicate defs. Fixed rkey would help (verify lexicon allows it). | +| F-47 | Correctness C-10 | `src/hooks/use-following.ts:106-113` | `refetch` runs without abort signal. | +| F-48 | Correctness C-17 | `src/lib/atproto/badges.ts:233-276` | `inflightEnsure` is release-on-resolve; small TTL cache would dedup back-to-back calls. | +| F-49 | Correctness C-18 | `src/app/api/groups/[groupDid]/location/route.ts:11-72` | `location` field shape unconstrained; narrow to known union variants. | +| F-50 | Correctness C-20 | `src/components/leaflet/leaflet-editor.tsx:148-155` | `setContent` silently coerces invalid JSONContent. Narrow risk. | +| F-51 | Architecture A-7 | `activity-detail.tsx` | Inline-edit extract (`useCertInlineEdit`). Defer per architecture agent. | +| F-52 | Architecture A-5 | `profile/[handle]/page.tsx` | Inline-edit extract (`useProfileInlineEdit`). Defer per architecture agent. | +| F-53 | Architecture A-12 | `src/lib/atproto/follow.ts`, `badges.ts` | Inline error parses; will collapse when F-11 lands. | +| F-54 | Architecture A-13 | `src/hooks/use-contributor-info.ts` vs `use-contributor-information.ts` | Names too close. Rename longer to `…Record`. | +| F-55 | Architecture A-14 | `useReceivedEndorsements` | No `refetch`. Add. | +| F-56 | Architecture A-15 | `profile/[handle]/page.tsx:768-771` | Cross-cutting `cache:"reload"` knowledge. Helper. (Rides with F-11 if it touches profile.ts.) | +| F-57 | Reuse R-3 | 8 new modals | `useFocusTrap` not used. But native `.showModal()` provides focus containment. Either wire it in (option a) or update AGENTS.md (option b). The reviewer flagged this as Medium; lens-6 P-1 explicitly notes the native behavior is sufficient. Going with option b (doc clarification) tonight, since adding `useFocusTrap` to 8 files is real surface area for no behavior change. | +| F-58 | Reuse R-12 | 8 new modals | Identical 15-line dialog skeleton. Extract `` primitive. Defer — substantial. | + +### Non-findings (explicitly empty — important for honesty) + +- **Modal class adherence** (`signin-modal app-modal`): 8/8 new modals on this branch carry both classes. No finding. +- **Provider tree scope**: no new context on this branch; the existing five are appropriately placed. (A-9) +- **`useUserProfile` vs `useProfile`** distinction: justified by different needs (read-render vs. read-then-write). (A-3) +- **`useGivenEndorsements` vs `useReceivedEndorsements`** shape difference: appropriate; their data is in different physical locations. (A-4) +- **`profile-endorsements.tsx`/`endorsement-lists.tsx` split**: coherent (tab orchestrator vs. Lists subsystem). (A-6) +- **Hook return shape variance**: driven by call-site needs; uniform-shaping would push work to consumers without saving anything. (A-10) +- **`` use**: exactly one tag, with `alt=""` for decorative + `loading="lazy"`. Correct. (Lens-6 P-6) +- **CSP diff**: only `frame-src` widened (YouTube/Vimeo for embeds); no regression to `script-src`/`connect-src`/etc. (My direct check.) +- **`Performance + a11y deep audit`**: not attempted tonight without browser/profiler infra. Light pass only. (Lens-6 by design.) + +### Out of scope tonight (explicitly deferred) + +| Item | Reason | +|---|---| +| `core` 4.5 GB dump file at repo root | Listed in April audit as F-026 low-priority cleanup; per safety rules, no destructive ops without explicit operator approval. | +| Tests / test framework | No test infra in the repo; introducing Vitest + RTL + first suite is L-effort and blows scope. Document as known. | +| Rate limiting infra (Upstash) | Audit F-020 still open; "requires infrastructure (Vercel/Upstash rate limit) — not fixable in code alone". | +| swapRecord concurrency on cert/profile/marker writes | Operator decision: PR #63 body acknowledged the `mergeProfile` race as "acceptable for v1". Same applies to cert (F-18). | +| Group BFF role enforcement at app tier (F-27) | Defensible architecture decision; documenting suffices. | +| AppDialog primitive (F-58) | Substantial refactor; defer to a focused PR. | +| Profile / cert inline-edit extraction (F-51, F-52) | Architecture agent recommends defer; not bug-shaped. | +| Indexer proxy restructure to operation allowlist (F-6 full) | Real refactor; tonight does the minimum mutation-block. | + +--- + +## Triage summary + +- **Critical:** 1 (F-1 leaflet XSS — fixable at 5 sites with one existing helper) +- **High:** 5 (F-2, F-3, F-4, F-5, F-6) +- **Medium:** 16 (F-7 through F-21 plus one in low list; some inter-dependent) +- **Low:** 37 (F-22 through F-58; many are nits, several are ride-alongs) + +Counts include the architecture and reuse "no-action / non-finding" items as separate lines for honesty, not as severity loadings. + +**Phase 3** (`03-implementation-plan.md`) decides which of these get fixed tonight and in what order. diff --git a/docs/overnight-2026-05-18/03-implementation-plan.md b/docs/overnight-2026-05-18/03-implementation-plan.md new file mode 100644 index 00000000..9e3ffade --- /dev/null +++ b/docs/overnight-2026-05-18/03-implementation-plan.md @@ -0,0 +1,288 @@ +# 03 — Implementation plan + +Phase 3 triage of `02-findings.md`. Per the deep-flow convention, each MUST-FIX item below carries a brief plan with **alternatives considered** and rationale. The rules of engagement set in `01-review-plan.md`: + +- Atomic commits, scope ceiling 400 lines / 8 files. +- Lowest-risk-highest-value first, independent before dependent, shared-surface last. +- After every commit: `npx tsc --noEmit` + `npm run lint` + `npm run build`. Never carry red. +- Mini re-review every 3-5 commits → `04-mini-review-N.md`. +- Stop when budget closes or all MUST-FIX done. Don't start what won't finish. +- Lint baseline before any commit: `tsc` clean, `npm run lint` = 45 problems (6 errors, 39 warnings), `npm run build` green. + +--- + +## Triage + +### MUST FIX TONIGHT (17 commits, in commit order) + +These are the items I plan to land tonight. Order is per the rule "lowest-risk-highest-value first, shared-surface last." + +| # | Finding(s) | Commit subject (draft) | Risk | Size | +|---|---|---|---|---| +| 1 | F-4 | `docs(env): declare missing INDEXER_URL, INDEXER_DID, NEXT_PUBLIC_STADIA_API_KEY in example` | none | tiny | +| 2 | F-2 + F-7 | `chore(lint): replace ref-during-render and dead ternary with useMemo` | low | small | +| 3 | F-1 (5+ findings) | `fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor (XSS)` | low | medium | +| 4 | F-9 + F-10 | `fix(api): echo 4xx upstream messages and clamp status in extractRouteError` | low | small | +| 5 | F-12 | `fix(api): replace bare console.error with logSafe in three group routes` | none | tiny | +| 6 | F-8 | `fix(api/groups/activity): allowlist record fields on PUT` | low | small | +| 7 | F-3 + F-22-24 + F-28 + F-39 | `fix(api/geocode): require session, sanitize 5xx, tighten input parsing` | low | small | +| 8 | F-6 (minimum) + F-4 prod-warn | `fix(api/indexer): reject mutation operations; warn on missing INDEXER_URL in prod` | low | small | +| 9 | F-5 | `fix(leaflet/editor): preserve cursor when external value catches up to editor` | medium | small | +| 10 | F-14 | `fix(hooks/use-session): clear handle/email/error on sign-out` | low | tiny | +| 11 | F-13 | `fix(activity-detail): revoke prior object URL on save + unmount` | low | small | +| 12 | F-21 | `fix(api/groups/follow): preserve client-supplied createdAt` | low | small | +| 13 | F-15 | `fix(leaflet): preserve ordered nested lists in linearDocument round-trip` | low | small | +| 14 | F-17 + F-34 (R-15) | `fix(hooks/social-graph-sync): abort importDids; isWriting in finally` | low | small | +| 15 | F-19 + F-20 + F-35 | `fix(styles): use --color-error token; remove 100vw; dedupe selector` | low | small | +| 16 | F-29 | `chore(deps): bump Next.js 16.2.3 → 16.2.6 (advisory chain)` | low | tiny | +| 17 | F-11 (+ ride-alongs: F-53, F-56) | `refactor(atproto): extract writeToRepo seam for dual-path writes` | medium | medium | + +**Expected duration:** ~4h. + +### WILL FIX TONIGHT IF TIME PERMITS + +Small, clean wins not on the critical path. Each is a single self-contained commit if I have margin after #17. + +- F-16 — `listFollowing` truncation flag. ~25 LOC across 3-4 files; touches hook return shape. +- F-31 — `extractAwardSubjectDid` validate with `isValidDid`. Tiny. +- F-32 — Preserve empty paragraphs (hard-break-only blocks) in `from-tiptap`. Tiny. +- F-33 — `useSession.fetchSession` `cancelled` sentinel. Tiny. +- F-30 — `useAuthorNamesMap` cache.delete on transient failure. Tiny. +- F-26 — Update AGENTS.md §17 #9 "four" → "the collections listed in `ALLOWED_WRITE_COLLECTIONS`". Doc-only. +- F-25 — Add Stadia referer-allowlist note to `.env.local.example` (ride F-4 if not already done). +- F-18 — Add "race window acknowledged" comment to `cert.ts` paralleling `mergeProfile`. Doc-only. + +### WILL NOT FIX TONIGHT (explicit deferrals) + +| Item | Reason | +|---|---| +| F-6 full restructure (server-held GraphQL queries) | Tonight's commit #8 does the minimum (`mutation` reject). Restructure is M+ effort, would change feed semantics — operator decision. | +| F-46 — fixed-rkey for default endorsement definition | Requires verifying lexicon allows fixed rkey on `badge.definition` (`key: "tid"` may be enforced). High-effort verification + irreversible scheme choice. Operator. | +| F-49 — narrow `location` body shape in group route | M-effort with downstream UI surface; defer to follow-up. | +| F-51, F-52 — profile page / activity-detail inline-edit extraction | Architecture agent recommends defer; not bug-shaped; surface area is wide. | +| F-54 — `useContributorInformation` rename | Naming-only; no bug; defer. | +| F-57 — `useFocusTrap` on 8 new `` modals OR AGENTS.md update | Native `.showModal()` provides focus containment; the AGENTS.md update is the smallest change but is operator copy. Defer with a doc note. | +| F-58 — extract `` primitive | Substantial; touches 8 files; defer to focused PR. | +| F-40, F-41 — hook return-shape harmonization | Cosmetic; defer. | +| F-37 — `--overlay-image{,-strong}` tokens | Defer; 6 hard-coded sites are tolerable until tokenization sprint. | +| F-42 — `border-radius: 14px` → token | Designer call; defer. | +| F-43, F-44 — handle shape regex on routes | The PDS rejects malformed handles; missing route-level pre-check is a UX nit. Defer. | +| F-45 — `parseLocationCoords` extra-comma input | Real but narrow; defer. | +| F-47 — `useFollowing.refetch` abort signal | Real but mostly invisible; defer unless rides with F-14/F-17. | +| F-48 — `inflightEnsure` result-cache TTL | Optimisation; no bug; defer. | +| F-50 — `setContent` invalid JSONContent | Risk is narrow; defer. | +| F-55 — `useReceivedEndorsements` add `refetch` | Real gap but defers cleanly. | +| F-38 — `` adoption in 4 new sites | Visual diff; defer until designer review. | +| F-36 — Delete stale `profile.css` BEM blocks | Tempting ride-along but the *override is correct today*; deleting risks visual regression if any selector path was missed. Defer. | +| F-27 — Document group BFF role-check delegation | Operator copy in AGENTS.md; defer. | +| `core` 4.5 GB dump file | Per safety rules: no destructive ops without operator approval. Listed in April audit as F-026. Skip. | +| Adding tests / test framework | Out of scope per project. | +| Rate-limiting infra | Requires Upstash rate-limit + per-route wiring; infra decision. | + +--- + +## Plans for each MUST-FIX commit + +Each plan follows the deep-flow shape: goal, scope, alternatives considered with rationale, acceptance criteria. + +### Commit 1 — `docs(env): declare missing env vars in example` + +- **Goal:** Stop new operators from booting a deploy that silently routes at the dev indexer (F-4). +- **Scope:** `.env.local.example` only. +- **Alternatives considered:** + - Add the vars + leave the `||` chain in the indexer route untouched. *Chosen* — the route warn comes in commit #8, separating doc and behavior changes. + - Remove `NEXT_PUBLIC_INDEXER_URL` alias entirely. *Rejected* — risks breaking any operator using it; mark as deprecated in a comment instead. +- **Acceptance:** All four vars appear in `.env.local.example` with comments explaining failure modes and the Stadia bundle-public note (F-25). + +### Commit 2 — `chore(lint): replace ref-during-render and dead ternary with useMemo` + +- **Goal:** Clear all 6 ESLint baseline errors (F-2 5×, F-7 1×). Establish "no new lint errors" as a meaningful claim. +- **Scope:** `src/hooks/use-user-indexer-activities.ts` (replace `useMergedDidsMap` with `useMemo`), `src/hooks/use-social-graph-sync.ts` (drop dead ternary, narrow dep on `certified.refetch`). +- **Alternatives for F-2:** + - Use `useSyncExternalStore`. *Rejected* — gigantic API surface for two-map merge. + - Move merge to a `useState`/`useEffect` pair. *Rejected* — same problem the original code tried to avoid (extra render). + - `useMemo` (chosen). Identical semantic for "stable reference unless inputs change." Lint-clean. Three lines. +- **Alternatives for F-7:** + - Convert refetch to plain async function (no `useCallback`). *Rejected* — callers depend on stable identity. + - Refactor `useFollowing` to return a memoized object literal. *Rejected* — touches a different file with broader consumer surface. + - `useCallback(() => certified.refetch(), [certified.refetch])` (chosen). +- **Acceptance:** `npm run lint` reports `0 errors` (was 6). Warnings stay at 39 or drop. + +### Commit 3 — `fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor (XSS)` + +- **Goal:** Close the F-1 XSS class. Use the **existing** `safeHttpUrl()` helper at every site where user-controlled URLs become DOM `href`s, or get serialized to / deserialized from a `pub.leaflet.*` record. +- **Scope (5 files, ~50 LOC):** + - `src/components/leaflet/leaflet-document.tsx` — wrap the iframe-fallback `` and the facet-link `` with `safeHttpUrl()`. When null, render plain text (no anchor). + - `src/components/leaflet/nodes/leaflet-iframe-node.tsx` — same iframe-fallback fix as above. + - `src/components/leaflet/leaflet-editor.tsx` — gate `handleLinkConfirm` on `safeHttpUrl()` before BOTH `setLink` and `insertContent` branches; show an inline error if rejected. + - `src/lib/leaflet/from-tiptap.ts` (`marksToFeatures`) — skip the FEATURE_LINK when `safeHttpUrl()` is null (defense in depth on write). + - `src/lib/leaflet/to-tiptap.ts` (`featureToMark`) — drop the link mark when `safeHttpUrl()` is null (defense in depth on read). +- **Alternatives considered:** + - **Add an editor-only validator** (TipTap `Link.configure({ validate })`). *Rejected* — `insertContent` bypasses `Link.setLink`'s validation (verified in C-2), and the renderer would still trust foreign records. The renderer is the load-bearing site. + - **Strip on the API write boundary** (`/api/xrpc/.../putRecord`). *Rejected* — XRPC proxy is content-agnostic and shouldn't grow lexicon-shaped validation. + - **Render-only fix** (skip writer / reader). *Rejected* — defense-in-depth on the writer prevents re-propagation under the user's identity from a malicious foreign source ↘ editor ↘ save. + - Helper choice: `safeHttpUrl()` accepts `http:`/`https:` only. The renderer's existing anchor opens in a new tab with `noreferrer`; `mailto:`/`tel:` would not work in that pattern anyway and the only documented use today is web links. (Chosen.) +- **Acceptance:** + - `` rendering a foreign record with a `javascript:` facet link renders plain text, not an anchor. + - The link dialog rejects a typed `javascript:alert(1)` URL with an inline error. + - Round-trip (foreign malicious record → edit → save) strips the malicious link mark. + - Build + lint + tsc still green. + +### Commit 4 — `fix(api): echo 4xx upstream messages and clamp status in extractRouteError` + +- **Goal:** Honor AGENTS.md §17 #7 (4xx may echo upstream) — currently the helper returns generic strings for ALL statuses. Also clamp arbitrary upstream status integers into valid HTTP range. +- **Scope:** `src/lib/utils/api.ts` only (the function `extractRouteError`). +- **Alternatives:** + - Per-route 4xx-echo logic. *Rejected* — duplicates the policy; the helper is the canonical seam. + - Pass a `{ echoOn4xx?: boolean }` option per call site. *Rejected* — every route would set it true; defaulting it makes the API trivial. + - **Always echo, clamp by helper** (chosen). 4xx echo of `err.message` after `redactSecrets`; 5xx stays generic. +- **Acceptance:** Calling the helper with a 400 carrying `err.message = "Handle must be at least 3 characters"` returns `{ status: 400, message: "Handle must be at least 3 characters" }`. With status 700 (or 0, or -1), returns 500. With status 503, returns 503 + generic message. + +### Commit 5 — `fix(api): replace bare console.error with logSafe in three group routes` + +- **Goal:** Stop leaking JWT/DPoP/Authorization tokens via the atproto SDK's `err.cause` chain into Vercel logs (F-12). +- **Scope:** `src/app/api/groups/[groupDid]/{profile,metadata,upload-blob}/route.ts`. +- **Alternatives:** none — the helper exists and is used elsewhere; this is a one-line edit per file. +- **Acceptance:** No `console.error(label, err)` calls in any group BFF route handler. Where `extractRouteError` already logs, the bare line is removed entirely (avoid duplicate log entries). + +### Commit 6 — `fix(api/groups/activity): allowlist record fields on PUT` + +- **Goal:** Close the mass-assignment regression vs. April audit's CS-005 (F-8). The activity route is the only group BFF write that doesn't field-allowlist. +- **Scope:** `src/app/api/groups/[groupDid]/activity/route.ts` only. +- **Alternatives:** + - **`pickAllowedFields` against an `ALLOWED_ACTIVITY_FIELDS` set** (chosen). Mirrors `metadata/route.ts` and `location/route.ts` patterns. + - Allowlist the entire lexicon. *Rejected* — current cert-edit form writes only `title`, `shortDescription`, `image`, `description` per `activity-detail.tsx:622-665`. Wider allowlist invites future drift. + - Approach: set the allowlist to what the form actually writes today + the immutable record-shape fields (`createdAt`). The cert-create flow may write more; verify against `src/app/create/page.tsx` before fixing the allowlist. +- **Acceptance:** Caller-supplied `record` body keys outside the allowlist are silently dropped (matching sibling routes' behavior). All existing cert-create / cert-edit happy paths still succeed (no regressions). + +### Commit 7 — `fix(api/geocode): require session, sanitize 5xx, tighten input parsing` + +- **Goal:** Close F-3 (open-internet abuse), F-22 (5xx echoing upstream status), F-23/F-24 (no `logSafe`), F-28 (`parseInt` non-strict), F-39 (silent oauth-restore failure in `getAuthenticatedAgent` — relevant since geocode will now require a session). +- **Scope:** `src/app/api/geocode/route.ts` + `src/lib/groups/proxy-agent.ts:164` (one logSafe). +- **Alternatives for F-3:** + - **Require session** (chosen). Geocode UI is auth-gated; legitimate users unaffected. + - Same-origin Referer check only. *Rejected* — Referer can be missing and shouldn't be required to fall through; less explicit than the auth gate. + - Upstash rate-limit on IP. *Rejected* — infra decision; defer. +- **Acceptance:** Anonymous `GET /api/geocode?q=…` returns 401. Authenticated request still resolves Nominatim. 5xx responses no longer carry upstream status in body. `parseInt` replaced with `Number()` + `Number.isInteger`. `getAuthenticatedAgent` logSafe on the catch. + +### Commit 8 — `fix(api/indexer): reject mutation operations; warn on missing INDEXER_URL in prod` + +- **Goal:** F-6 minimum + F-4 production-warn. +- **Scope:** `src/app/api/indexer/route.ts`. +- **Alternatives for mutation reject:** + - **Reject any body whose JSON-parsed `query` starts with `mutation`** (after trim/leading-comment strip — chosen as minimum). + - Server-held queries + operation-name allowlist (the right answer). *Deferred* — restructure; tonight does the minimum. +- **Alternatives for warn:** + - **Module-load `console.warn` when `NODE_ENV === "production"` and no INDEXER_URL set** (chosen). Mirrors notifications/route.ts:34. + - Throw at module load. *Rejected* — would brick local dev / preview builds where the dev URL is genuinely the right answer. +- **Acceptance:** `query: "mutation { … }"` returns 400 from the proxy. Production deploy without `INDEXER_URL` logs a loud warn at boot. + +### Commit 9 — `fix(leaflet/editor): preserve cursor when external value catches up to editor` + +- **Goal:** Fix cursor reset on every keystroke (F-5). +- **Scope:** `src/components/leaflet/leaflet-editor.tsx` value-sync effect only. +- **Alternatives:** + - Compare `next` against `editor.getJSON()`. *Chosen* — direct fix; the editor is the source of truth for current content, not `lastExternalRef`. + - Update `lastExternalRef.current` from `onUpdate`. *Considered* — also works, but ties the ref update to editor lifecycle, which is more brittle. + - Skip the effect when `tiptapToLinearDocument(editor.getJSON())` already shallow-matches `value`. *Rejected* — round-trip cost on every keystroke; the direct compare is cheaper. +- **Acceptance:** Manually exercise: type a sentence in a leaflet field on cert edit; verify cursor stays at the typing position; verify external content (e.g. switching certs) still loads the new content. + +### Commit 10 — `fix(hooks/use-session): clear handle/email/error on sign-out` + +- **Goal:** F-14. Long-lived components must observe a sign-out promptly. +- **Scope:** `src/hooks/use-session.ts` (the `isAuthenticated → false` branch). +- **Alternatives:** none material; setHandle(null) + setEmail(null) + setError(null). +- **Acceptance:** A component mounted before sign-out, still mounted after, reads `null` handle/email. + +### Commit 11 — `fix(activity-detail): revoke prior object URL on save + unmount` + +- **Goal:** F-13. Object URL leak. +- **Scope:** `src/components/feed/activity-detail.tsx` only. +- **Alternatives:** + - Track both `pending` and `local` URLs and revoke the latter on next pick + on unmount (chosen). + - Move image lifecycle into a hook. *Rejected* — premature; the file already has tight image lifecycle. +- **Acceptance:** Manual: open cert edit, pick image A, save, re-edit, pick image B, save → no DOM `Blob`s remain for image A. Unmounting during edit revokes the current pending URL. + +### Commit 12 — `fix(api/groups/follow): preserve client-supplied createdAt` + +- **Goal:** F-21. Social-graph sync should preserve original Bluesky follow times. +- **Scope:** `src/app/api/groups/[groupDid]/follow/route.ts` + caller `src/lib/atproto/follow.ts` (`createFollow` accepts `createdAt?` and passes through for group writes). +- **Alternatives:** + - Accept an optional ISO-8601 `createdAt` on the body, validate format, pass through. (Chosen.) + - Always pass through; reject if missing. *Rejected* — breaks current callers that don't supply. +- **Acceptance:** Body with valid `createdAt: "2024-…"` writes that value to the group repo. Missing/invalid → fall back to server time (current behavior preserved). + +### Commit 13 — `fix(leaflet): preserve ordered nested lists in linearDocument round-trip` + +- **Goal:** F-15. Data-loss bug on edit. +- **Scope:** `src/lib/leaflet/from-tiptap.ts` (writer-side switch between `children` and `orderedListChildren`). +- **Alternatives:** none; the writer is asymmetric and the reader already handles both fields. +- **Acceptance:** Author a nested ordered list in a leaflet, save, reopen → still an ordered list. + +### Commit 14 — `fix(hooks/social-graph-sync): abort importDids; isWriting in finally` + +- **Goal:** F-17 + F-34 (R-15). Prevent loop continuation after modal close; prevent `isWriting` leak on refetch error. +- **Scope:** `src/hooks/use-social-graph-sync.ts` + the modal caller in `src/components/settings/sync-social-graph-section.tsx`. +- **Alternatives:** the modal wraps the import call in an `AbortController` tied to its lifecycle; `importDids` accepts `{ signal? }` and checks between iterations. +- **Acceptance:** Closing the modal during import stops further writes (verified by adding a `for` loop trace in dev). `isWriting` is always cleared via `finally`, including when the post-loop `refetch()` throws. + +### Commit 15 — `fix(styles): use --color-error token; remove 100vw; dedupe selector` + +- **Goal:** F-19 + F-20 + F-35. +- **Scope:** + - 16 sites across 3 CSS files: replace `var(--danger, #d44)` with `var(--color-error)`. + - `src/app/styles/leaflet.css:466`: replace `100vw` with `100%`. + - `src/app/styles/cert-detail.css:75,116`: merge duplicate `.cert-detail__image` blocks. +- **Alternatives for F-19:** + - Declare `--danger` in tokens.css. *Rejected* — adds a parallel token; AGENTS.md §11 rule 3 says reuse, not invent. + - Leave as `var(--danger, #d44)` and accept the dark-theme bug. *Rejected* — designer convention is theme-responsive errors. +- **Acceptance:** Dark mode renders errors in `#f87171`; light mode renders in `#ba1a1a`. No `100vw` in any CSS under `src/app/styles/`. `.cert-detail__image` defined exactly once. + +### Commit 16 — `chore(deps): bump Next.js 16.2.3 → 16.2.6 (advisory chain)` + +- **Goal:** F-29. Patch within minor, closes the published advisory chain. +- **Scope:** `package.json` + `package-lock.json` only. +- **Alternatives:** none material — patch version is the minimum-risk choice. +- **Acceptance:** `npm audit` shows 0 high advisories on Next.js. `npm run build` still green. + +### Commit 17 — `refactor(atproto): extract writeToRepo seam for dual-path writes` + +- **Goal:** F-11. Single shared seam for "target vs. own → CGS BFF vs. XRPC" across the 5 lib helpers. +- **Scope (6 files, ~80-120 LOC added / ~130-160 removed = net ~-40 LOC):** + - New: `src/lib/atproto/repo-write.ts` — exports `writeToRepo(ownDid, targetDid, op, group, errorFallback)`. + - Edit: `src/lib/atproto/cert.ts` (`putCertRecord`). + - Edit: `src/lib/atproto/profile.ts` (`putProfile`). + - Edit: `src/lib/atproto/location.ts` (`putLocationRecord`). + - Edit: `src/lib/atproto/follow.ts` (`createFollow`, `deleteFollow`). + - Edit: `src/lib/groups/org-marker.ts` (`putOrgMarker`). +- **Alternatives considered:** + - **Thin helper that takes XRPC op + group route spec** (chosen — architecture agent's proposed signature). + - **Per-collection helpers** (`writeProfile`, `writeFollow`, …) over a shared registry of routes. *Rejected* — would introduce a separate parallel API to maintain. + - **Defer entirely** until a forcing function lands. *Rejected* — error-extraction and parameter-shape divergence is already a real maintenance cost across 5 files; the helper is within scope. + - Return shape: helper always returns `{uri, cid} | null` from the upstream — callers can ignore (`putProfile` does today). Strictly additive. +- **Acceptance:** + - All five call sites compile and pass tsc. + - Manual: write a personal profile, write a group profile, write a personal cert, write a group cert, follow a user as self, follow a user as group, write a personal location, write a group location, write an org marker. Each should succeed against the same upstream as before. + - Build/lint/tsc green. + - `follow.ts` no longer reimplements its own error-parsing; uses shared `extractError`. + +--- + +## Mini-re-review checkpoints + +After commits **5**, **10**, and **15**, run a mini re-review (per the brief): did anything I just did introduce a new problem, regress a test, contradict an earlier fix? Capture in `04-mini-review-N.md`. + +Mini-review questions to answer at each checkpoint: +- Did `npm run lint` count change unexpectedly? +- Does `npm run build` still complete? +- Did any of the just-committed files cross-affect a hook or component I didn't touch (search for new imports)? +- Did I inadvertently widen any security surface (CSP, allowlist, env)? +- Are the commit messages atomic and accurate? + +--- + +## Stopping plan + +I will pause and re-evaluate after **commit 8** (~half the list). If implementation is on track for ~4h, I'll continue with commits 9-17. If it has slipped past ~3h elapsed implementation, I'll prune the back-half: skip the IF-TIME items entirely, evaluate dropping commit 17 (the largest) and noting it for follow-up. + +I will not start commit 17 (the dual-path refactor) if there is less than 75 minutes of budget remaining — it needs full attention and a quality re-review of each of the 5 call sites. diff --git a/docs/overnight-2026-05-18/04-mini-review-1.md b/docs/overnight-2026-05-18/04-mini-review-1.md new file mode 100644 index 00000000..d9b134e8 --- /dev/null +++ b/docs/overnight-2026-05-18/04-mini-review-1.md @@ -0,0 +1,37 @@ +# 04 — Mini re-review #1 (after commit 5) + +Five commits in. Check-back per the plan's checkpoint cadence. + +## Diff snapshot + +``` +b85d45f docs(env): declare missing env vars in .env.local.example +65630f3 chore(lint): clear baseline by replacing ref-during-render and broken memoization +e43edba fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor +94ba191 fix(api): echo 4xx upstream messages and clamp status in extractRouteError +eee165d fix(api): drop duplicate console.error in three group routes; rely on extractRouteError logSafe +``` + +## Mini-review questions + +1. **Did `npm run lint` count change unexpectedly?** Yes, expectedly: from `45 problems (6 errors, 39 warnings)` to `38 problems (0 errors, 38 warnings)`. The 6 errors are eliminated (commit 2). One warning dropped — likely a related react-hooks/refs warning on the same line as the eliminated error. + +2. **Does `npm run build` still complete?** Yes. Compiled in 3.5s; 41/41 static pages. + +3. **Did any of the just-committed files cross-affect a hook or component I didn't touch?** + - `extractRouteError` is consumed by every group BFF route, the geocode route, and the notifications route. The 4xx-echo policy change is semantically broader behavior across all of them — users now see actionable upstream validation messages instead of generic strings. Inspected each consumer briefly; no caller assumes the old generic strings. + - `safeHttpUrl` was already an internal helper; introducing six new call sites doesn't widen its API. + - The two lint commits touched two hooks; the destructuring in `use-social-graph-sync` changed the closure shape but no public API. + +4. **Did I inadvertently widen any security surface?** No. Three commits are tightening (XSS allowlist, 4xx clamp/redact, logSafe coverage). Two are docs / lint. The 4xx echo could be argued to leak more upstream detail, but it passes through `redactSecrets` and is exactly what AGENTS.md §17 #7 prescribes. + +5. **Are the commit messages atomic and accurate?** Yes. Each commit does one named thing. The XSS commit is the largest (7 files) but the surface is conceptually unified — every site where user-controlled URLs cross the renderer/editor boundary. + +## Cross-effects worth a second look + +- The 4xx echo change interacts with the `geocode` route's planned commit 7 — that commit will use the helper too. No conflict; the helper improvement is upstream. +- The `safeHttpUrl` integration touches both directions of the linearDocument codec; commit 13 (preserve ordered nested lists) will touch the writer side. No conflict. + +## Verdict + +On track. Lint baseline is now clean, the critical XSS is closed, and the most consequential API helper has been tightened. Continuing with commits 6-8 next. diff --git a/docs/overnight-2026-05-18/04-mini-review-2.md b/docs/overnight-2026-05-18/04-mini-review-2.md new file mode 100644 index 00000000..835e6d64 --- /dev/null +++ b/docs/overnight-2026-05-18/04-mini-review-2.md @@ -0,0 +1,38 @@ +# 04 — Mini re-review #2 (after commit 10) + +Ten commits in. Half the MUST-FIX list landed (per `03-implementation-plan.md`). + +## Commits since last mini-review + +``` +89da494 fix(api/groups/activity): allowlist record fields on PUT to close mass-assignment +048855b fix(api/geocode): require session, sanitize 5xx, tighten input parsing +c404817 fix(api/indexer): reject mutation operations; warn on missing INDEXER_URL in production +24a8084 fix(leaflet/editor): preserve cursor when external value catches up to editor +ac72a8c fix(hooks/use-session): clear handle/email/error on sign-out +``` + +## Mini-review questions + +1. **Did `npm run lint` count change unexpectedly?** No — still 38 problems (0 errors, 38 warnings). The lint baseline is stable. + +2. **Does `npm run build` still complete?** Yes — verified after commit 8 (last build run); the subsequent two commits are small enough not to risk it. + +3. **Cross-effects?** + - The activity-route allowlist could in principle drop a real lexicon field the form writes. Cross-checked `ACTIVITY_FIELDS` against `ClaimActivity` in `activity-types.ts` — coverage is complete (the 12-field list matches the lexicon's required + optional fields). Cert-create writes only title/shortDescription/createdAt; cert-edit's inline path spreads `effectiveValue` which can include any of the 12. No regression. + - The geocode session requirement is a behavior change for any caller that today reaches /api/geocode anonymously. Verified no such caller exists (`grep -rn /api/geocode src/` returns only the location-picker which sits on auth-gated pages). + - The cursor-preservation fix is the riskiest commit so far — the old `lastExternalRef` comparison was load-bearing for "external resets". The new comparison against `editor.getJSON()` handles that case correctly (external resets produce a `next` that doesn't shallow-match current), but it's the kind of fix that wants browser exercise. **Flagged for the operator's morning verification.** + - useSession sign-out clearing is additive; couldn't regress anything that wasn't already broken. + +4. **Security surface widened?** No. Activity allowlist narrows. Geocode auth narrows. Indexer mutation block narrows. The cursor fix and sign-out clear are not security-shaped. + +5. **Commit messages atomic and accurate?** Yes. Two of the five commits combine related concerns (geocode + proxy-agent logSafe; indexer mutation reject + prod warn) — each combination is explicitly justified in the commit body. + +## Cross-effects worth a second look + +- **Cursor fix needs browser exercise** before final declaration. Adding a note in `05-final-review.md` and the hand-off summary. +- The four-line geocode-auth change interacts with the `useGeocode` hook on the client — if it doesn't use `authFetch`, a 401 from `/api/geocode` won't trigger the session-expiry UI. Worth quick check. + +## Verdict + +10/17 done. Continuing through commits 11-17. diff --git a/docs/overnight-2026-05-18/04-mini-review-3.md b/docs/overnight-2026-05-18/04-mini-review-3.md new file mode 100644 index 00000000..27e7fb5a --- /dev/null +++ b/docs/overnight-2026-05-18/04-mini-review-3.md @@ -0,0 +1,56 @@ +# 04 — Mini re-review #3 (after commit 17) + +Final mini-review before Phase 6 (full re-review). 17 commits landed; the planned commit 17 (writeToRepo helper) was reshaped into a smaller normalization. Reasoning recorded below. + +## Commits since mini-review #2 + +``` +1fd99c6 fix(activity-detail): revoke prior object URL on save + unmount +a0479be fix(api/groups/follow): preserve client-supplied createdAt +a2dc45e fix(leaflet): preserve ordered nested lists in linearDocument round-trip +402fde2 fix(hooks/social-graph-sync): thread abort signal through importDids; isWriting in finally +122965a fix(styles): use --color-error token; drop 100vw; merge duplicate cert-detail__image rule +952a343 chore(deps): bump Next.js 16.2.3 -> 16.2.6 (high-severity advisory chain) +08e0691 chore(atproto/follow): use extractError to match sibling write helpers +``` + +## On commit 17's reshape + +The original plan was to extract a `writeToRepo(ownDid, targetDid, op, group, errorFallback)` helper used by all five dual-path write sites (cert, profile, location, follow, org-marker). The architecture-lens reviewer flagged this as Medium severity and "the single highest-value architecture finding." + +Sitting down to do it, the body shapes across the five sites turn out to diverge enough that a single helper either: + +1. **Forces a discriminated-union argument** carrying every shape variant the BFF routes accept (cert: `{rkey, record}`; profile: bare metadata; follow: `{subjectDid, createdAt}` + server-built record; location: `{rkey, record}` with rkey-conditional XRPC method; org-marker: bare metadata + fixed rkey=self) — more boilerplate at each call site than the current branch. +2. **Has callers pre-shape both branches** before calling — same boilerplate as today, just relocated. + +The "drift" between sites turned out to be cosmetic (error string format in `follow.ts` diverging from the rest's use of `extractError`), not structural. Fixing the cosmetic part is a small targeted change; the abstraction would burn complexity for shallow savings. + +The brief explicitly bans introducing patterns without concrete pain. The architecture finding is real but the proposed remedy doesn't fit the surface as cleanly as on paper. Done the smaller fix (`extractError` normalization in follow.ts); recorded the full helper as deferred in `02-findings.md`'s F-11 and in the operator hand-off. + +## Mini-review questions + +1. **Did `npm run lint` count change unexpectedly?** No — still 38 problems (0 errors, 38 warnings). + +2. **Does `npm run build` still complete?** Yes — verified after the Next.js bump (the most consequential of the recent commits); CSS commit was static-only. The two later commits (follow normalization and abort-signal plumbing) are small enough. + +3. **Cross-effects?** + - The Next.js bump touched `package-lock.json` heavily. tsc and build both pass; no runtime change expected within-minor. + - The CSS `--color-error` change has a visible diff in error rendering colors (`#d44` → `#ba1a1a` in light, `#f87171` in dark). This is an intentional alignment with the existing token; flag for operator's morning eye. + - The abort-signal plumbing changed the `importDids` signature. Verified the only call site is the modal in `sync-social-graph-section.tsx`, which I updated. + +4. **Security surface widened?** No. Next.js patch tightens. CSS doesn't touch security. The `extractError` normalization in follow doesn't change exposure. + +5. **Commit messages atomic and accurate?** Yes. The Next.js commit body explicitly documents the remaining postcss audit chain as deferred — not silently swept under. + +## Final state + +- 17 atomic commits, all green at tsc + lint + build. +- Lint baseline: 0 errors (was 6) / 38 warnings (was 39). +- Critical (1): closed. +- High (5): closed (F-2, F-3, F-4, F-5, F-6 minimum). +- Medium (16): 11 closed, 5 explicitly deferred per finding (F-11 full helper; F-16 truncation surface; F-49 location-shape narrow; F-18 cert race acknowledgment doc-only; F-58 AppDialog primitive). +- Low (37): a handful closed as ride-alongs (F-22-F-24-F-28-F-39 with the geocode commit; F-25 with the env-docs commit), most explicitly deferred per the implementation plan's WILL-NOT-FIX list. + +## Verdict + +Branch is clean, gates are green, all MUST-FIX done, the one reshape was driven by re-evaluating the change in front of the code and choosing the smaller win. Moving to Phase 6 (final re-review). diff --git a/docs/overnight-2026-05-18/05-final-review.md b/docs/overnight-2026-05-18/05-final-review.md new file mode 100644 index 00000000..19b89509 --- /dev/null +++ b/docs/overnight-2026-05-18/05-final-review.md @@ -0,0 +1,314 @@ +# 05 — Final re-review + +Fresh-eyes pass over the 17 commits between `ad6668c` and HEAD on +`feat/positioning-redesign`. I have not read the earlier reviewer +passes' calibrations; what follows is from reading the diffs and code +under cwd, not from believing what was said. + +--- + +## Per-commit walk + +1. **`b85d45f` docs(env): declare missing env vars** — Adds `INDEXER_URL`, + `INDEXER_DID`, `NEXT_PUBLIC_INDEXER_URL`, `NEXT_PUBLIC_STADIA_API_KEY`, + and the group-service pair to `.env.local.example`. Docs-only. No + runtime change. **Risk: none.** + +2. **`65630f3` chore(lint): clear baseline** — `useMergedDidsMap` collapses + to `useMemo(() => mergeMaps(a, b), [a, b])`; `useSocialGraphSync` + destructures `useFollowing` / `useBlueskyFollows` and drops a dead + `bluesky ? Promise.resolve() : Promise.resolve()` ternary. Hook return + contract is unchanged (same field names + types in the returned + object). I checked the call sites: `sync-social-graph-section.tsx` + only reads `stats`, `isLoading`, `error`, `refetch`, `importDids`, + `certifiedCount`, `blueskyCount` — all preserved. **Risk: low.** + +3. **`e43edba` fix(leaflet) scheme-allowlist user-controlled URLs** — + Seven files touched, applying `safeHttpUrl` at every emit/persist + point. Renderer fallbacks degrade to `` on rejection (content + stays visible, no one-click attack). Editor's `insertContent` path + now mirrors what TipTap's `setLink` validates. Writer + reader both + defend, so a malicious foreign record neither persists into the + user's repo on save nor renders as an active anchor on read. CSS + adds a new `.link-dialog__error` matching the existing hint style. + No other render path emits user-controlled `href`: I grepped for + `href={` across `src/components/leaflet/`, `feed/`, `profile/` — + the only non-`` `` in the changed feature surfaces is + `location-card.tsx`'s `osmUrl(coords)` which is server-built from + numeric coords. Iframe `src=` on lines 250 / 80 of the two + leaflet files is reached only after `isAllowedEmbedHost` + (hostname allowlist) passes — `javascript:` has no hostname and is + rejected. **Risk: low; this is purely tightening.** + +4. **`94ba191` fix(api) extractRouteError 4xx + clamp** — 4xx now echoes + `err.message` (passed through a local Bearer/DPoP/JWT redactor) + instead of a generic string; status clamped to `[200,599]` else + 500. Touches one helper used by 14 routes (see grep output). I + checked downstream string-equality consumers: only + `use-org-marker.ts:79` does `body?.error === "RecordNotFound"`, and + that catches an *upstream* xrpc error (which gets echoed verbatim + under the new policy — better, not worse). No caller depends on + the old generic `"Bad request"` / `"Not found"` text. The + redactor's regexes are anchored on token shapes, not status text, + so they don't strip legitimate validation messages. **Risk: low.** + +5. **`eee165d` fix(api) drop duplicate console.error** — Three group + routes (`profile`, `metadata`, `upload-blob`) drop their bare + `console.error(label, err)` and pass a route-tagged prefix to + `extractRouteError` instead, so `logSafe` (with redaction) handles + the logging. Behavior change: error logs are slightly shorter and + one redaction-bypass path closed. **Risk: none.** + +6. **`89da494` fix(api/groups/activity) allowlist** — `ALLOWED_ACTIVITY_FIELDS` + pulled from the `ClaimActivity` lexicon, routed through the + existing `pickAllowedFields` helper. Field set looks complete + against `src/lib/atproto/activity-types.ts`. If the cert-edit form + ever sends a key not in the list it'll be silently dropped — I'd + flag this as the highest-attention spot for the next manual smoke + pass (try editing and saving a cert in every supported field). + **Risk: low if the lexicon list is faithful; medium if a field + was missed — would silently drop on save.** + +7. **`048855b` fix(api/geocode)** — Adds `getSessionDid()` gate (returns + 401 anonymously). Replaces `parseInt` with `Number()+isInteger` on + the limit. Replaces `Upstream returned ${status}` body with a + generic message, with the status moved into `logSafe`. Replaces + bare `console.error` with `logSafe`. Also adds `logSafe` to the + silent `client.restore` failure in `proxy-agent.ts`. The only + caller is `src/lib/locations/geocode.ts`. **Risk: low.** + +8. **`c404817` fix(api/indexer) mutation gate + prod warn** — + `isLikelyMutation()` strips leading whitespace and `#` line + comments, then checks the first 8 chars. Defeats the obvious + smuggling vector. Caveats worth noting: a multi-operation document + `query A { … } mutation B { … }` with `operationName: "B"` would + bypass the prefix check; the GraphQL spec then requires + `operationName` for execution, but a non-strict upstream might + default to the first op. The commit body explicitly defers the full + allowlist restructure. Acceptable as a bare-minimum guard. + **Risk: low; not a complete mutation block, but the comment says so.** + +9. **`24a8084` fix(leaflet/editor) preserve cursor** — Compares + `toInitialDoc(value)` against `editor.getJSON()` rather than a + stored ref. `shallowEqual` is `JSON.stringify` equality. Concern: + if `linearDocumentToTipTap` ever emits a doc that is semantically + identical to what the editor currently holds but differs in some + default attr (e.g. `attrs: { level: 1 }` vs absent), `shallowEqual` + reports inequality and `setContent` fires unnecessarily. For typed + text this round-trips cleanly. External replacements (parent + resets `value` to a wholly different doc) still flow through — + `current` is the old typed text, `next` is the new doc, they + differ, setContent runs. **Risk: low for the common path; an + asymmetric round-trip is the lurking risk and would be visible as + a still-resetting cursor in some narrow case (e.g. headings). + Worth a manual smoke pass.** + +10. **`ac72a8c` fix(hooks/use-session) clear on sign-out** — Three + `setX(null)` calls added to the sign-out branch. Correct fix for + a real bug. **Risk: none.** + +11. **`fc4d746` fix(locations) authFetch for geocode** — Three sites in + `geocode.ts` switched from `fetch` to `authFetch` so the new 401 + from commit 7 surfaces through the expiry UI. **Risk: none.** + +12. **`1fd99c6` fix(activity-detail) revoke object URLs** — Save path + revokes prior `localImageUrl` before promotion. Unmount cleanup + uses refs (`pendingImagePreviewUrlRef.current = …` reassigned + every render, then the unmount-only effect closes over the + current value). The refs are *written* during render — under React + 19 strict mode the render may run twice, but assignment is + idempotent and the refs are unmount-scoped, so no leak / no double + revoke. The `b !== a` guard prevents double-revoke when pending + was promoted to local before unmount. **Risk: low.** + +13. **`a0479be` fix(api/groups/follow) preserve client createdAt** — + Additive (optional `createdAt` on body, validated via `Date.parse`). + `createFollow` plumbs the param through. **Note: latent — the sole + consumer that would benefit (`useSocialGraphSync.importDids`) does + not pass `createdAt` because `useBlueskyFollows` only returns a + `Set` of DIDs, no timestamps.** The plumbing is correct + and ready for the day Bluesky follow records carry through with + their `createdAt`. **Risk: low; latent value.** + +14. **`a2dc45e` fix(leaflet) ordered nested lists** — Writer splits + nested `bulletList` → `children` vs `orderedList` → + `orderedListChildren.children`, mirroring the reader's existing + asymmetric branch. Type-checked against + `src/lib/leaflet/types.ts:ListItem`. **Risk: low.** + +15. **`402fde2` fix(hooks/social-graph-sync) abort + isWriting** — + `importDids` takes `opts?: { signal? }`, checks `signal.aborted` + between iterations. `SyncModal` owns an `AbortController`, + aborts on unmount. `setIsWriting(false)` moves into `finally` + so a refetch failure no longer leaves the modal stuck. The hook + return-type expansion (`importDids` now `(dids, opts?) => …`) is + additive — the existing single-arg call site in + `sync-social-graph-section.tsx` is updated; no other callers exist + (grep confirms). **Risk: low.** + +16. **`122965a` fix(styles) `--color-error` + drop `100vw` + merge dup + rule** — 16 replacements of `var(--danger, #d44)` with + `var(--color-error)`. `--danger` was undeclared so `#d44` + dark-mode-broken color was always used; new behavior gets proper + theming. `100vw` → `100%` in `.long-description-modal`. Duplicate + `.cert-detail__image` merged. **Semantic concern: 14 of the 16 + replacements are clearly error/destructive (revoke, remove, + failed, error). Two are `--counter--warn` classes + (`endorse-people-modal__reason-counter--warn` and + `endorse-reason-modal__counter--warn`) on character-count + "approaching limit" UI — semantically a warning, not an error. The + project has a `--color-warning` token (`tokens.css:87`, + light `#F5A623` / dark `#fbbf24`).** This was wrong with `--danger` + too, but the rename cements the wrong semantic and makes the + "this is meant to be a warning" intent harder to recover. Not a + regression; pre-existing semantic drift. Trivial fix: + `--counter--warn` → `var(--color-warning)`. + +17. **`952a343` chore(deps) Next 16.2.3 → 16.2.6** — Patch-within-minor. + The commit body correctly identifies that none of the patched + advisories (CSP-nonce XSS, cache poisoning, middleware bypass) + map onto this app's surface (no middleware, no CSP nonces, no + Image Optimization disk cache). Verified `npm audit` drops to + 3 moderate (all postcss-via-next), tsc clean, lint baseline + unchanged, build green. **Risk: low.** + +18. **`08e0691` chore(atproto/follow) extractError** — Two inline + `data.error || ${fallback}: ${res.status}` blocks in + `createFollow`, plus the one in `deleteFollow`, replaced with + `await extractError(res, fallback)`. Splits "transport failure" + from "upstream returned no record reference" into distinct + throws so log greps can tell them apart. **Risk: none.** + +--- + +## New issues introduced this night? + +Two minor items, both pre-existing in nature but worth flagging. + +- **F-A (low, semantic):** Commit 16 (CSS rename) sweeps two + `--counter--warn` classes into `var(--color-error)` along with the + legit error classes. These are character-counter "approaching the + limit" UI; the project already has `--color-warning` tokens for + exactly this. Two-line fix in `profile-endorsements.css:1073` and + `:1368`. The previous `var(--danger, #d44)` was equally wrong; + this commit didn't introduce the drift, it just cemented it. + +- **F-B (informational):** Commit 13 (`createdAt` plumbing through + `createFollow` and the group BFF) is fully wired API-side but the + only consumer that would care (`useSocialGraphSync.importDids`) + doesn't pass a timestamp because `useBlueskyFollows` returns DIDs + without their original record timestamps. The new field is latent + until the bluesky-follows hook surfaces `createdAt`. Not a bug; + just a "completed plumbing for a downstream change that hasn't + landed yet." + +I looked for these specifically and did not find: + +- Any newly-introduced `` outside the + guarded leaflet sites. +- Any hook-return contract drift in commits 2 or 15. +- Any caller that breaks on the `extractRouteError` message change. +- A stale closure on the object-URL refs in commit 12. +- Any consumer of `/api/geocode` not updated to `authFetch`. +- A breaking change in Next 16.2.4-6 that touches this codebase. + +--- + +## Verification: gates green? + +| Gate | Result | +|---|---| +| `npx tsc --noEmit` | **PASS** (exit 0, no output) | +| `npm run lint` | **38 problems: 0 errors, 38 warnings** — matches the target | +| `npm run build` | **PASS** (with dummy `.env.local`, all 27 API routes + static pages built) | +| `npm audit` | 0 high, 3 moderate (all postcss-via-next; documented as deferred) | + +Gates match the brief's target exactly. Lint went from `45 / 6 errors` +to `38 / 0 errors`; no new warnings introduced. + +--- + +## Scoring: before vs. after + +- **Problem framing:** **before 7 / after 8.** The orientation+findings + docs framed the work crisply: each finding has a site, a severity, + a proposed direction, and a "what I'd want a reviewer to focus on" + trail. The commits read the same way — each message has a + before-and-after explanation a reviewer can follow without context. + +- **Approach:** **before 7 / after 8.** Chose defense-in-depth on the + XSS (renderer + editor + (de)serializer + dialog) rather than + patching only the most-visible site. Chose the minimum + mutation-block on the indexer rather than the full restructure + (and said so in the commit body). Chose to use `useMemo` rather + than disable the lint rule, and to use refs-not-deps on the + unmount-only object-URL cleanup with an explanation of why a + deps array would be wrong. All of these are real engineering + choices made the right way. + +- **Code quality:** **before 7 / after 8.** Helpers reused + (`safeHttpUrl`, `pickAllowedFields`, `extractError`, + `extractRouteError`, `logSafe`); shared token used + (`--color-error`). Comments explain the why of every non-obvious + change. Two small drag points: (a) the `--counter--warn` semantic + drift noted above; (b) the `shallowEqual = JSON.stringify` + comparison in the cursor-preservation fix is correct for the + common case but doesn't survive `attrs`-default asymmetries + between TipTap's emit and `linearDocumentToTipTap`'s output. Neither + is bug-shaped today; both are the next reviewer's nit pile. + +- **Robustness:** **before 6 / after 8.** Concrete steps up: XSS + closed at the boundary AND on the write/read paths; group BFF + activity route no longer mass-assigns; geocode no longer leaks + Nominatim quota; indexer no longer accepts trivial mutation + smuggling; geocode 401 surfaces through the expiry UI; object URLs + no longer leak through the page lifetime; sign-out no longer leaks + stale identity to long-lived consumers; ordered nested lists no + longer round-trip as bullets; abort path on social-graph sync; + `extractRouteError` no longer hides actionable upstream messages + or emits non-standard status codes. + +- **Evolvability:** **before 6 / after 7.** The dual-path + `writeToRepo` helper (F-11) was considered and deferred with a + written rationale (call sites diverge in body shape such that one + helper would burn complexity for shallow savings) — the minimum + slice landed as `extractError` normalization on follow.ts. That's + a defensible call. Activity field allowlist is now grep-able and + the obvious "where do I add a new activity field" lives in one + array. The leaflet renderer/editor split now has a single + scheme-allowlist seam (`safeHttpUrl`) that any future code + adding `` to user content can grep for. Negative: no test + suite was introduced, so all of these are still un-pinned + against regression. That's a known operator-decision, not a + reviewer finding. + +--- + +## Verdict + +A tight, honest night's work. The 17 commits cluster around one +critical (leaflet XSS) closed across all the right sites, two +operability holes patched (geocode auth, indexer mutation gate), a +handful of real bugs fixed (cursor reset, object-URL leak, stale +session identity, ordered-list round-trip, abort-on-unmount), and a +broad set of small hygiene improvements (extractRouteError policy, +console.error → logSafe, CSS token reuse, mass-assignment +allowlist, ordered-nested lists). Every commit has a clear scope, a +before/after explanation, and respects the documented conventions +(`Co-Authored-By`, no emojis in code, `safeHttpUrl` at the boundary, +`logSafe` over bare console.error, `--color-error` over hard-coded +hex). Gates match the brief's target (tsc clean, 38/38 lint with +0 errors, build green, audit 0 high). + +Two minor things worth landing as a follow-on if the operator has +budget: (a) `--counter--warn` classes should be `var(--color-warning)` +not `var(--color-error)`; (b) the `createdAt` plumbing through +`createFollow` is latent until `useBlueskyFollows` surfaces the +original timestamp. Neither blocks merge. + +My recommendation: this is mergeable into staging as-is; the human +reviewer's attention is best spent on (1) manually exercising the +cert-edit form to confirm the activity field allowlist isn't dropping +anything legitimate, and (2) typing a paragraph plus a heading in the +leaflet editor to confirm the cursor-preservation fix holds across +node types. diff --git a/next.config.ts b/next.config.ts index 313bae50..f617af40 100644 --- a/next.config.ts +++ b/next.config.ts @@ -33,7 +33,15 @@ const nextConfig: NextConfig = { }, { key: "Content-Security-Policy", - value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com https://vercel.live; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; frame-src 'self' https://vercel.live; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + // `frame-src` allowlists the iframe sources we explicitly + // support: Vercel's preview comments overlay, the leaflet + // linearDocument embed providers (YouTube + Vimeo). Without + // these origins listed here, the rendered iframes show + // YouTube's "This content is blocked. Contact the site + // owner to fix the issue." in-frame message — which is + // YouTube itself reacting to being framed from a page + // whose CSP forbids it. + value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com https://vercel.live; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; frame-src 'self' https://vercel.live https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", }, ], }, diff --git a/package-lock.json b/package-lock.json index f7a29296..c645815c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,16 @@ "@atproto/api": "^0.13.20", "@atproto/jwk-jose": "^0.1.11", "@atproto/oauth-client-node": "^0.3.17", + "@tiptap/extension-link": "^3.23.4", + "@tiptap/extension-placeholder": "^3.23.4", + "@tiptap/pm": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", "@upstash/redis": "^1.36.3", "@vercel/analytics": "^2.0.1", "leaflet": "^1.9.4", "lucide-react": "^0.468.0", - "next": "16.2.3", + "next": "16.2.6", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -29,7 +34,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.2.6", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" @@ -741,6 +746,34 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -881,15 +914,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", - "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz", - "integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", "dev": true, "license": "MIT", "dependencies": { @@ -897,9 +930,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", - "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", "cpu": [ "arm64" ], @@ -913,9 +946,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", - "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", "cpu": [ "x64" ], @@ -929,9 +962,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", - "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", "cpu": [ "arm64" ], @@ -945,9 +978,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", - "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", "cpu": [ "arm64" ], @@ -961,9 +994,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", - "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", "cpu": [ "x64" ], @@ -977,9 +1010,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", - "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", "cpu": [ "x64" ], @@ -993,9 +1026,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", - "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", "cpu": [ "arm64" ], @@ -1009,9 +1042,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", - "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", "cpu": [ "x64" ], @@ -1093,6 +1126,447 @@ "tslib": "^2.8.0" } }, + "node_modules/@tiptap/core": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.4.tgz", + "integrity": "sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.4.tgz", + "integrity": "sha512-7YjSibNlPcy9eGK+tHt5G/Njr7nPxl+rZ3rCC6TwtLIRLSHPnoGDsfFOgTPkXxaQcE1a/VQwemnYfWc3kdIjDQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.4.tgz", + "integrity": "sha512-3L9tnZ12i+98u5df2nV2zGu/sc3rhI87E3ocn1YYAO8PJUAgZnMwdet8JawCrS1uut5sRKlxo3SXEmdNfRVm/w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.4.tgz", + "integrity": "sha512-EPTpL/IFp/aTGZErBq/Mc3dKznj6G/qNEkVYWjueOn1oKApyT0P6WVHGvu/vpMdErhzmoGDuFPPGVS6T8Upx2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.4.tgz", + "integrity": "sha512-mXB2KZOz1R+E6VNTZ3vzdAk7ZDGKjPmsJEZIQg1B5qRycTKg49/rCCkLA2QnqAwX6BzS3mLLH1RWE2W0oXD7vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.4.tgz", + "integrity": "sha512-C0TeRipMycUEBnV+Mzx6eLp/yZb6Vi/waP3Tkb0lO5/ikg7LWLB7AlmMunjIXEUcR/pJHID/aEh5PfJFpysUDg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.4.tgz", + "integrity": "sha512-UEU1w/85CSNKktbhESnIRmtjKcH7DeschReZA8err1wAnYLTKzid5ucnJSJ25iRg2V5Fnuws5gnPT5CVgdfXCQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.4.tgz", + "integrity": "sha512-YC4G6VkxT629rlqUTwD6XvOpxjvghn7fxrK4RbyKVJY2C6E1vgmX0won1Ast6v+qTE6iONOMS6f6VyPxSGjg4w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.4.tgz", + "integrity": "sha512-ujJQUIENk0RwVFCh5g/TOSEv1a7Pnam/cjHmSUqHWUNZkYS9aOqjm+JfURJPCinRS2oHvo3AARul5mkKgDJYcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.4.tgz", + "integrity": "sha512-eAc72bKM26yIPx0jsU8qdjE71vFNVu5R9jGbdItBMFc0SPLS4qY8g+8RJ+iWoLwbcSEpgooLS9D9sLfdAU+Tvw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.4.tgz", + "integrity": "sha512-RuyvOlIGP6UpVOc0Lw0L63jKLtYM49CNhPV2OMSfwwwbBZ3pJGos2/SqpYg71d3sn+qpsAopS4Pfr8iPZog73A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.4.tgz", + "integrity": "sha512-ODlpZCi7n136BH9luM09EFL8Pg+bbRCd0tzCQM5BKMXRkLitYZA8Gl/f5DLmGJ50wzFsDPXK2Br2g9UvZK7COg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.4.tgz", + "integrity": "sha512-8W9Hqi0J69Xbqg08nPf4xRMJXMccaKFAgUE1tvy5PAWJSQxOMwkKQXgZXxwe+80sOMUnV8qveBqUy/ODMPgAxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.4.tgz", + "integrity": "sha512-EA4kK8ywZ4dQNOdxeZbplmDDs5T5LjMgHpqxRwukj9wwKiILOK5E3fcKm1fCKh9Q02w96jax6YVccHwmgJP3sQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.4.tgz", + "integrity": "sha512-jUAHi+HZlg47BzgVIy6y/UH5vev7vPQ95jddhB5K3hC122kvWFMXlken7UOnqzbxNcHs2+4Oi/ZJirYMpT4P5w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.4.tgz", + "integrity": "sha512-XjxltY7MomwfTs6jmN6Bw5bb/upb34lpyqv2RiXppFTK25Br7ipksRjUpWpB4/csZeg30qwrLGVKxCol38ffrw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.4.tgz", + "integrity": "sha512-yuauDm6qW/7q+ZO0YJBKQEGdnUm6DDTJM8AMp9bMZrT4jRf/zyUtNcZ91QEfFvBcyVuI+10PIOXtNPevhQ741Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.4.tgz", + "integrity": "sha512-Q/JXosShD5oyDwukE6igdrZD2lb0ZgyoQTHYchk0pzU4frClFbn3RI1wKP+XeqKLhdO6KH2WZ9rERGH7PtDi7Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.4.tgz", + "integrity": "sha512-9FezifCfuoc0o+5K6l4QNOOfelqxnDGg/f9oL1D/LFZPC54bPxpWWft9QCWOqyqZgyLCLjbCjciAlbgkrFUmmw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.4.tgz", + "integrity": "sha512-+3ofyssYnOTa1+nFWEmCAY1ngn8nAV1xo25JnNNC87NMU9WkSgr93jB7/uUJP0uui1C2dBLlaup3XXm108yarw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.4.tgz", + "integrity": "sha512-KbhXjCFzWphvFn5VU7E4dtmYDm+bssI1i0+CnXPWCXkjdaaX88ck68Xp1fKz8/bbI/CqlgiNDO/3TvqgtZ6woQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.4.tgz", + "integrity": "sha512-yHtAZkFR9M2AQmCi555w4ns1BBCqwRyYDYMtd10DBvqPX7T3TmGerPdUfI6sLr74GxnZ5zHOnOYdwAbeG5JzNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.4.tgz", + "integrity": "sha512-Vnq5vW801zPbu1LtKeA5k4R241jY+hRjXeijYwIPxy15KzIiipY12518HiCf6/8kkRbMxgOfdYg9X4BRV3HV3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.4.tgz", + "integrity": "sha512-q9kxver/MR18p66aWZHSPycnr9hcBFyVGeGj8gf+BQCzn5hpvtSYTfLvk1nq8GFhygdQ9/e3f7B5ovrm/jnpvw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.4.tgz", + "integrity": "sha512-F1ocPT10LV+seky25R1TMCRdc/Iof99jLcDSYDGr6mNEDY4ct2RvOeSM8aDdYq6CkH+vXt3i3JDeRwV23KzswQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.4.tgz", + "integrity": "sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.4.tgz", + "integrity": "sha512-+C5ngcoza47n3MjtjVBqBEBICPC0McdbwzJ+X6SSCviCLoqnSYanv5mIX9HWG0Q4fJ4BkdNM3VibZUxQaTbKyQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.4.tgz", + "integrity": "sha512-mb5aIY9PuLreOVLExqs+8BAI20I/8+jCUBfEIqheuFY2GRRuBiwczejSlYuADfVDBbPVN5uPw4UMADCaH5wueQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.23.4", + "@tiptap/extension-floating-menu": "^3.23.4" + }, + "peerDependencies": { + "@tiptap/core": "3.23.4", + "@tiptap/pm": "3.23.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.4.tgz", + "integrity": "sha512-3VhU+NO6/ec9DMj/5Ej0nzARSq42cXnqW+QHCmTL3FNXkXQz+tw1KlfruT5GGJ3M0RssjWjRC0a39N/4S3qxeA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.23.4", + "@tiptap/extension-blockquote": "^3.23.4", + "@tiptap/extension-bold": "^3.23.4", + "@tiptap/extension-bullet-list": "^3.23.4", + "@tiptap/extension-code": "^3.23.4", + "@tiptap/extension-code-block": "^3.23.4", + "@tiptap/extension-document": "^3.23.4", + "@tiptap/extension-dropcursor": "^3.23.4", + "@tiptap/extension-gapcursor": "^3.23.4", + "@tiptap/extension-hard-break": "^3.23.4", + "@tiptap/extension-heading": "^3.23.4", + "@tiptap/extension-horizontal-rule": "^3.23.4", + "@tiptap/extension-italic": "^3.23.4", + "@tiptap/extension-link": "^3.23.4", + "@tiptap/extension-list": "^3.23.4", + "@tiptap/extension-list-item": "^3.23.4", + "@tiptap/extension-list-keymap": "^3.23.4", + "@tiptap/extension-ordered-list": "^3.23.4", + "@tiptap/extension-paragraph": "^3.23.4", + "@tiptap/extension-strike": "^3.23.4", + "@tiptap/extension-text": "^3.23.4", + "@tiptap/extension-underline": "^3.23.4", + "@tiptap/extensions": "^3.23.4", + "@tiptap/pm": "^3.23.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "dev": true, @@ -1144,7 +1618,6 @@ }, "node_modules/@types/react": { "version": "19.2.14", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1152,12 +1625,17 @@ }, "node_modules/@types/react-dom": { "version": "19.2.3", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", @@ -2065,7 +2543,6 @@ }, "node_modules/csstype": { "version": "3.2.3", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2469,13 +2946,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz", - "integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.3", + "@next/eslint-plugin-next": "16.2.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -2825,6 +3302,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -3805,6 +4291,12 @@ "dev": true, "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -3948,12 +4440,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", - "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.3", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -3967,14 +4459,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.3", - "@next/swc-darwin-x64": "16.2.3", - "@next/swc-linux-arm64-gnu": "16.2.3", - "@next/swc-linux-arm64-musl": "16.2.3", - "@next/swc-linux-x64-gnu": "16.2.3", - "@next/swc-linux-x64-musl": "16.2.3", - "@next/swc-win32-arm64-msvc": "16.2.3", - "@next/swc-win32-x64-msvc": "16.2.3", + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { @@ -4207,6 +4699,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "dev": true, @@ -4497,6 +4995,135 @@ "react-is": "^16.13.1" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.6", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.6.tgz", + "integrity": "sha512-RIm+e9BiqAaJ1mRECv3vR3C+VG8ELoTTI+47tVudGi82yLnFOx3G/p/iSPK1HmHQdKhkkrJ68NJqxh7S+FBVmQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -4684,6 +5311,12 @@ "node": ">=0.10.0" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -6268,11 +6901,26 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, "license": "MIT" }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/package.json b/package.json index 020dde0c..3ca66408 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,16 @@ "@atproto/api": "^0.13.20", "@atproto/jwk-jose": "^0.1.11", "@atproto/oauth-client-node": "^0.3.17", + "@tiptap/extension-link": "^3.23.4", + "@tiptap/extension-placeholder": "^3.23.4", + "@tiptap/pm": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", "@upstash/redis": "^1.36.3", "@vercel/analytics": "^2.0.1", "leaflet": "^1.9.4", "lucide-react": "^0.468.0", - "next": "16.2.3", + "next": "16.2.6", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -30,7 +35,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.2.6", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" diff --git a/src/app/activity/[did]/[rkey]/page.tsx b/src/app/activity/[did]/[rkey]/page.tsx index 30d557b5..8de7c05b 100644 --- a/src/app/activity/[did]/[rkey]/page.tsx +++ b/src/app/activity/[did]/[rkey]/page.tsx @@ -2,13 +2,17 @@ import { useMemo } from "react" import { useParams } from "next/navigation" -import { usePageTitle } from "@/lib/navbar-context" +import { usePageTitleBreadcrumb } from "@/lib/navbar-context" import { useActivity } from "@/hooks/use-activity" +import { useAuthorInfo } from "@/hooks/use-author-info" import ActivityDetail from "@/components/feed/activity-detail" import LoadingSpinner from "@/components/ui/loading-spinner" export default function ActivityDetailPage() { - usePageTitle("Activity") + // No plain-string fallback — the breadcrumb below renders once + // author + cert data resolve; until then the top-bar title slot + // stays empty rather than flashing a generic "Activity" word that + // never matches what the user clicked. const params = useParams() const did = useMemo(() => { @@ -23,11 +27,29 @@ export default function ActivityDetailPage() { }, [params.rkey]) const { activity, isLoading, error } = useActivity(did, rkey) + const { info: authorInfo } = useAuthorInfo(did) + + const handle = authorInfo?.handle ?? null + const certTitle = activity?.value.title ?? null + usePageTitleBreadcrumb( + handle && certTitle && did && rkey + ? { + left: { + text: handle, + href: `/profile/${encodeURIComponent(handle)}`, + }, + right: { + text: certTitle, + href: `/activity/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`, + }, + } + : null + ) if (isLoading) { return ( -
-
+
+
@@ -36,12 +58,12 @@ export default function ActivityDetailPage() { if (error || !activity) { return ( -
-
-

+

+
+

{error || "Activity not found"}

-

+

This activity claim may have been deleted or is on a PDS we can’t reach.

@@ -51,7 +73,7 @@ export default function ActivityDetailPage() { } return ( -
+
) diff --git a/src/app/api/geocode/route.ts b/src/app/api/geocode/route.ts new file mode 100644 index 00000000..c43608a2 --- /dev/null +++ b/src/app/api/geocode/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionDid } from "@/lib/auth/session" +import { logSafe } from "@/lib/utils/log-safe" + +/** + * Proxy for Nominatim (OpenStreetMap's geocoding service). Two modes: + * + * - `GET /api/geocode?q=` — forward geocoding (text → coords). + * - `GET /api/geocode?lat=&lon=` — reverse geocoding + * (coords → display name). + * + * Why proxy instead of letting the browser hit Nominatim directly: + * 1. Nominatim's usage policy requires a meaningful `User-Agent` + * identifying the app. Browsers don't allow overriding UA on + * fetch(); only server-to-server calls can comply. + * 2. Edge cache (s-maxage) on the response collapses repeat + * lookups across users — common queries ("New York", "London") + * return without re-hitting upstream. + * 3. Keeps the user's IP off Nominatim's logs; our server's IP + * hits them instead. + * + * Auth: requires an authenticated session. The geocode UI is only + * mounted on edit screens (cert / profile / group), which are all + * auth-gated; gating the route too closes an open-internet abuse + * surface (anonymous traffic can otherwise burn through our + * Nominatim quota and rate-limit our egress IP for legitimate users). + * + * Attribution requirement: callers (the location picker UI) display + * "© OpenStreetMap contributors" near the map. + */ + +const NOMINATIM_BASE = "https://nominatim.openstreetmap.org" +const USER_AGENT = "Certified (https://certified.app; contact@certified.app)" + +interface NominatimForwardHit { + lat: string + lon: string + display_name: string + importance?: number + type?: string + class?: string +} + +interface NominatimReverseHit { + lat: string + lon: string + display_name: string + error?: string +} + +export interface GeocodeForwardResult { + lat: number + lng: number + displayName: string +} + +export interface GeocodeReverseResult { + lat: number + lng: number + displayName: string +} + +const CACHE_HEADERS = { + // Forward + reverse hits are stable for a given input on human + // timescales. 24h shared cache is fine; the UI debounces the + // user's typing so we're not flooding cache anyway. + "Cache-Control": "public, max-age=300, s-maxage=86400", +} as const + +export async function GET(request: NextRequest) { + const did = await getSessionDid() + if (!did) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const url = request.nextUrl + const q = url.searchParams.get("q") + const lat = url.searchParams.get("lat") + const lon = url.searchParams.get("lon") + const limitRaw = url.searchParams.get("limit") + + try { + if (q !== null) { + // Forward geocode. `limit` controls how many hits we return — + // the autocomplete dropdown asks for 5-8; the legacy + // single-hit caller leaves it unset. + const trimmed = q.trim() + if (trimmed.length === 0 || trimmed.length > 200) { + return NextResponse.json({ error: "invalid q" }, { status: 400 }) + } + // Number() rather than parseInt — parseInt silently truncates + // "3.7" to 3 and accepts trailing garbage ("3abc"). Number + + // isInteger rejects both consistently. + const parsedLimit = limitRaw === null ? 1 : Number(limitRaw) + const limit = Number.isInteger(parsedLimit) + ? Math.min(10, Math.max(1, parsedLimit)) + : 1 + + const upstream = new URL(`${NOMINATIM_BASE}/search`) + upstream.searchParams.set("q", trimmed) + upstream.searchParams.set("format", "json") + upstream.searchParams.set("limit", String(limit)) + upstream.searchParams.set("addressdetails", "0") + const res = await fetch(upstream.toString(), { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + signal: AbortSignal.timeout(8_000), + }) + if (!res.ok) { + // Never echo upstream status in the body (AGENTS.md §17 #7). + // logSafe so operators can still diagnose Nominatim health. + logSafe("[geocode] upstream non-2xx", undefined, { + status: res.status, + }) + return NextResponse.json( + { error: "Geocoding upstream unavailable" }, + { status: 502 }, + ) + } + const body = (await res.json()) as NominatimForwardHit[] + const results: GeocodeForwardResult[] = [] + for (const hit of body) { + const latNum = parseFloat(hit.lat) + const lngNum = parseFloat(hit.lon) + if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) continue + results.push({ + lat: latNum, + lng: lngNum, + displayName: hit.display_name, + }) + } + // Preserve the legacy single-hit response shape for callers + // that don't pass `limit`. Multi-hit callers get `results[]`. + if (limit === 1) { + return NextResponse.json( + { result: results[0] ?? null }, + { headers: CACHE_HEADERS }, + ) + } + return NextResponse.json({ results }, { headers: CACHE_HEADERS }) + } + + if (lat !== null && lon !== null) { + // Reverse geocode. + const latNum = parseFloat(lat) + const lonNum = parseFloat(lon) + if (!Number.isFinite(latNum) || !Number.isFinite(lonNum)) { + return NextResponse.json({ error: "invalid lat/lon" }, { status: 400 }) + } + if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) { + return NextResponse.json({ error: "out of range" }, { status: 400 }) + } + const upstream = new URL(`${NOMINATIM_BASE}/reverse`) + upstream.searchParams.set("lat", String(latNum)) + upstream.searchParams.set("lon", String(lonNum)) + upstream.searchParams.set("format", "json") + upstream.searchParams.set("zoom", "12") // city / town level — keeps display_name short + const res = await fetch(upstream.toString(), { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + signal: AbortSignal.timeout(8_000), + }) + if (!res.ok) { + // Never echo upstream status in the body (AGENTS.md §17 #7). + // logSafe so operators can still diagnose Nominatim health. + logSafe("[geocode] upstream non-2xx", undefined, { + status: res.status, + }) + return NextResponse.json( + { error: "Geocoding upstream unavailable" }, + { status: 502 }, + ) + } + const body = (await res.json()) as NominatimReverseHit + if (body.error || !body.display_name) { + return NextResponse.json({ result: null }, { headers: CACHE_HEADERS }) + } + const result: GeocodeReverseResult = { + lat: latNum, + lng: lonNum, + displayName: body.display_name, + } + return NextResponse.json({ result }, { headers: CACHE_HEADERS }) + } + + return NextResponse.json( + { error: "missing q or lat/lon" }, + { status: 400 }, + ) + } catch (err) { + logSafe("[geocode] upstream error", err) + return NextResponse.json({ error: "Geocoding failed" }, { status: 502 }) + } +} diff --git a/src/app/api/groups/[groupDid]/activity/route.ts b/src/app/api/groups/[groupDid]/activity/route.ts new file mode 100644 index 00000000..fc5cc7d0 --- /dev/null +++ b/src/app/api/groups/[groupDid]/activity/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server" +import { + getAuthenticatedAgent, + createGroupAgent, +} from "@/lib/groups/proxy-agent" +import { checkCsrf } from "@/lib/auth/csrf" +import { isValidDid } from "@/lib/utils/did" +import { extractRouteError, parseJsonBody, pickAllowedFields } from "@/lib/utils/api" + +const ACTIVITY_COLLECTION = "org.hypercerts.claim.activity" + +// Mirror the `org.hypercerts.claim.activity` lexicon (see +// `src/lib/atproto/activity-types.ts:ClaimActivity`). Allowlist on the +// BFF in line with sibling routes (`/profile`, `/metadata`, +// `/location`) — see AUDIT_REPORT.md CS-005 and AGENTS.md §17 #6. +const ALLOWED_ACTIVITY_FIELDS = [ + "title", + "shortDescription", + "createdAt", + "shortDescriptionFacets", + "description", + "image", + "contributors", + "workScope", + "startDate", + "endDate", + "locations", + "rights", +] as const + +/** + * PUT /api/groups/[groupDid]/activity + * + * Write (overwrite) an existing `org.hypercerts.claim.activity` + * record on a group's repo. Used by the cert detail page's inline + * edit when the cert lives on a group's PDS — group admins / owners + * who've switched into the group can update title / short + * description / image / description through this endpoint. + * + * Body shape: + * { rkey: string, record: } + * + * `rkey` is required — this route only updates an existing cert. + * Creating a fresh one happens through the activity-creation flow, + * not the inline-edit surface. + * + * Returns `{ uri, cid }` so the client can mirror the new commit + * locally without a re-read. + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> }, +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const parsed = await parseJsonBody(request, "[groups/activity]") + if (!parsed.ok) return parsed.response + const body = (parsed.body ?? {}) as Record + const rkey = typeof body.rkey === "string" ? body.rkey : null + if (!rkey) { + return NextResponse.json( + { error: "rkey is required" }, + { status: 400 }, + ) + } + const rawRecord = body.record + if (!rawRecord || typeof rawRecord !== "object") { + return NextResponse.json( + { error: "record is required" }, + { status: 400 }, + ) + } + // Allowlist record fields. Without this, every property on the + // caller's body — including unknown / future / accidental keys — + // gets persisted on the group's repo. CGS may also validate + // upstream but defense-in-depth on the BFF matches the pattern + // used by sibling group routes (profile/metadata/location). + const record = pickAllowedFields( + rawRecord as Record, + ALLOWED_ACTIVITY_FIELDS, + ACTIVITY_COLLECTION, + ) + + const groupAgent = createGroupAgent(auth.agent, groupDid) + const upstream = await groupAgent.call( + "app.certified.group.repo.putRecord", + {}, + { + repo: groupDid, + collection: ACTIVITY_COLLECTION, + rkey, + record, + }, + { encoding: "application/json" }, + ) + + const data = (upstream as unknown as { data?: { uri?: string; cid?: string } }) + .data + const uri = typeof data?.uri === "string" ? data.uri : null + const cid = typeof data?.cid === "string" ? data.cid : null + if (!uri || !cid) { + return NextResponse.json( + { error: "Upstream returned no record reference" }, + { status: 502 }, + ) + } + return NextResponse.json({ uri, cid }) + } catch (err: unknown) { + const { status, message } = extractRouteError(err) + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/src/app/api/groups/[groupDid]/follow/route.ts b/src/app/api/groups/[groupDid]/follow/route.ts new file mode 100644 index 00000000..c51746c6 --- /dev/null +++ b/src/app/api/groups/[groupDid]/follow/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server" +import { + getAuthenticatedAgent, + createGroupAgent, +} from "@/lib/groups/proxy-agent" +import { checkCsrf } from "@/lib/auth/csrf" +import { isValidDid } from "@/lib/utils/did" +import { extractRouteError, parseJsonBody } from "@/lib/utils/api" + +const FOLLOW_COLLECTION = "app.certified.graph.follow" + +/** + * POST /api/groups/[groupDid]/follow + * + * Create an `app.certified.graph.follow` record on a GROUP's repo + * (record's `subject` is the foreign account being followed). Used + * by the sync flow when an owner/admin acts as a group and wants + * the group itself to follow accounts — without this BFF route the + * client-side `createFollow` writes to the personal repo instead. + * + * Body shape: + * { subjectDid: string, createdAt?: string } + * + * `createdAt` is optional. When present it must be a valid ISO-8601 + * timestamp; the route passes it through unchanged so the + * social-graph sync flow can preserve the user's original follow + * timestamp from Bluesky. When absent the server stamps the record + * with the current time. + * + * Returns `{ uri, cid }` so the client can mirror the new commit + * locally without a re-read. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> }, +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const parsed = await parseJsonBody(request, "[groups/follow]") + if (!parsed.ok) return parsed.response + const body = (parsed.body ?? {}) as Record + const subjectDid = typeof body.subjectDid === "string" ? body.subjectDid : null + if (!subjectDid || !isValidDid(subjectDid)) { + return NextResponse.json( + { error: "subjectDid is required and must be a valid DID" }, + { status: 400 }, + ) + } + + // Accept and validate an optional client-supplied createdAt. + // Sync flow uses it to preserve the original Bluesky follow + // time; absent → stamp now. Validate as a parseable ISO-8601 + // string so a junk value doesn't end up on the group's repo. + const createdAtRaw = + typeof body.createdAt === "string" ? body.createdAt : null + const createdAt = + createdAtRaw && !Number.isNaN(Date.parse(createdAtRaw)) + ? createdAtRaw + : new Date().toISOString() + + const record = { + $type: FOLLOW_COLLECTION, + subject: subjectDid, + createdAt, + } + + const groupAgent = createGroupAgent(auth.agent, groupDid) + const upstream = await groupAgent.call( + "app.certified.group.repo.createRecord", + {}, + { + repo: groupDid, + collection: FOLLOW_COLLECTION, + record, + }, + { encoding: "application/json" }, + ) + + const data = (upstream as unknown as { data?: { uri?: string; cid?: string } }) + .data + const uri = typeof data?.uri === "string" ? data.uri : null + const cid = typeof data?.cid === "string" ? data.cid : null + if (!uri || !cid) { + return NextResponse.json( + { error: "Upstream returned no record reference" }, + { status: 502 }, + ) + } + return NextResponse.json({ uri, cid }) + } catch (err: unknown) { + const { status, message } = extractRouteError(err) + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/src/app/api/groups/[groupDid]/location/route.ts b/src/app/api/groups/[groupDid]/location/route.ts new file mode 100644 index 00000000..783688ae --- /dev/null +++ b/src/app/api/groups/[groupDid]/location/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server" +import { + getAuthenticatedAgent, + createGroupAgent, +} from "@/lib/groups/proxy-agent" +import { checkCsrf } from "@/lib/auth/csrf" +import { isValidDid } from "@/lib/utils/did" +import { extractRouteError, parseJsonBody } from "@/lib/utils/api" + +const LOCATION_COLLECTION = "app.certified.location" +const ALLOWED_LOCATION_FIELDS = new Set([ + "$type", + "lpVersion", + "srs", + "locationType", + "location", + "name", + "description", + "createdAt", +]) + +/** + * PUT /api/groups/[groupDid]/location + * + * Write a `app.certified.location` record on a group's repo. The + * group's profile editor uses this to persist the inline location + * picker's `(name, lat, lng)`; the resulting strongRef is then + * referenced from the group's `app.certified.actor.organization` + * marker. + * + * Body shape: + * { rkey?: string, record: } + * + * When `rkey` is provided we putRecord (in-place update); otherwise + * createRecord with a PDS-assigned TID. Returns `{ uri, cid }` so the + * caller can embed the strongRef. + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> }, +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const parsed = await parseJsonBody(request, "[groups/location]") + if (!parsed.ok) return parsed.response + const body = (parsed.body ?? {}) as Record + const rkey = typeof body.rkey === "string" ? body.rkey : undefined + const rawRecord = body.record + if (!rawRecord || typeof rawRecord !== "object") { + return NextResponse.json( + { error: "record is required" }, + { status: 400 }, + ) + } + + // Allowlist-filter the record body — drops any unexpected fields + // a misbehaving client might try to ship in. + const record: Record = {} + for (const [k, v] of Object.entries(rawRecord)) { + if (ALLOWED_LOCATION_FIELDS.has(k)) record[k] = v + } + record.$type = LOCATION_COLLECTION + + const groupAgent = createGroupAgent(auth.agent, groupDid) + + const method = rkey + ? "app.certified.group.repo.putRecord" + : "app.certified.group.repo.createRecord" + const requestBody = rkey + ? { + repo: groupDid, + collection: LOCATION_COLLECTION, + rkey, + record, + } + : { + repo: groupDid, + collection: LOCATION_COLLECTION, + record, + } + + const upstream = await groupAgent.call(method, {}, requestBody, { + encoding: "application/json", + }) + + const data = (upstream as unknown as { data?: { uri?: string; cid?: string } }) + .data + const uri = typeof data?.uri === "string" ? data.uri : null + const cid = typeof data?.cid === "string" ? data.cid : null + if (!uri || !cid) { + return NextResponse.json( + { error: "Upstream returned no strongRef" }, + { status: 502 }, + ) + } + return NextResponse.json({ uri, cid }) + } catch (err: unknown) { + const { status, message } = extractRouteError(err) + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/src/app/api/groups/[groupDid]/metadata/route.ts b/src/app/api/groups/[groupDid]/metadata/route.ts index 6c555897..ffe22f55 100644 --- a/src/app/api/groups/[groupDid]/metadata/route.ts +++ b/src/app/api/groups/[groupDid]/metadata/route.ts @@ -8,7 +8,14 @@ import { checkCsrf } from "@/lib/auth/csrf" import { isValidDid } from "@/lib/utils/did" import { extractRouteError, pickAllowedFields, parseJsonBody } from "@/lib/utils/api" -const METADATA_FIELDS = ["organizationType", "urls", "location", "foundedDate", "createdAt"] as const +const METADATA_FIELDS = [ + "organizationType", + "urls", + "location", + "foundedDate", + "longDescription", + "createdAt", +] as const /** * GET /api/groups/[groupDid]/metadata @@ -45,8 +52,9 @@ export async function GET( const data = await res.json() return NextResponse.json(data.value) } catch (err: unknown) { - console.error("GET org metadata error:", err) - const { status, message } = extractRouteError(err) + // extractRouteError calls logSafe internally; bare console.error + // duplicated the log and skipped the redactSecrets pass. + const { status, message } = extractRouteError(err, "[groups/metadata/get]") return NextResponse.json({ error: message }, { status }) } } diff --git a/src/app/api/groups/[groupDid]/profile/route.ts b/src/app/api/groups/[groupDid]/profile/route.ts index b8f38213..0041e58b 100644 --- a/src/app/api/groups/[groupDid]/profile/route.ts +++ b/src/app/api/groups/[groupDid]/profile/route.ts @@ -96,8 +96,10 @@ export async function PUT( return NextResponse.json({ success: true }) } catch (err: unknown) { - console.error("PUT org profile error:", err) - const { status, message } = extractRouteError(err) + // extractRouteError calls logSafe internally; bare console.error + // would duplicate the log line and bypass the redactSecrets pass + // that strips JWT/DPoP material from the atproto SDK's cause chain. + const { status, message } = extractRouteError(err, "[groups/profile]") return NextResponse.json({ error: message }, { status }) } } diff --git a/src/app/api/groups/[groupDid]/upload-blob/route.ts b/src/app/api/groups/[groupDid]/upload-blob/route.ts index 7e7fa649..6064e999 100644 --- a/src/app/api/groups/[groupDid]/upload-blob/route.ts +++ b/src/app/api/groups/[groupDid]/upload-blob/route.ts @@ -60,8 +60,10 @@ export async function POST( return NextResponse.json(data) } catch (err: unknown) { - console.error("Upload blob error:", err) - const { status, message } = extractRouteError(err) + // extractRouteError calls logSafe internally — no separate + // console.error needed (would duplicate the log line and would + // also bypass the redactSecrets pass). + const { status, message } = extractRouteError(err, "[groups/upload-blob]") return NextResponse.json({ error: message }, { status }) } } diff --git a/src/app/api/indexer/route.ts b/src/app/api/indexer/route.ts index 018630fd..3f14076b 100644 --- a/src/app/api/indexer/route.ts +++ b/src/app/api/indexer/route.ts @@ -22,6 +22,21 @@ const UPSTREAM_INDEXER_URL = process.env.NEXT_PUBLIC_INDEXER_URL || "https://magic-indexer-dev.up.railway.app/graphql" +// Mirror the module-load warning the notifications route already has +// (src/app/api/notifications/route.ts:34) — without this a production +// deploy that forgets to set INDEXER_URL silently routes every feed +// query at the dev indexer, returning stale or inconsistent data. +if ( + process.env.NODE_ENV === "production" && + !process.env.INDEXER_URL && + !process.env.NEXT_PUBLIC_INDEXER_URL +) { + console.warn( + "[indexer] no INDEXER_URL set in production — falling back to the dev " + + "instance. Set INDEXER_URL in the Vercel project env.", + ) +} + /** Hard cap on the upstream request — matches the indexer's typical * warm-cache response time (~500ms) with generous headroom. */ const UPSTREAM_TIMEOUT_MS = 15_000 @@ -88,6 +103,29 @@ export async function POST(request: NextRequest) { ) } + // Block mutation operations. The indexer's public /graphql endpoint + // is for reads; the notifications mutations live on a separate + // /notifications/graphql endpoint reached via the + // /api/notifications proxy with operation allowlisting. Without + // this guard, an XSS payload (or any same-origin context) could + // call arbitrary GraphQL operations through us. + // + // The detection trims leading whitespace + GraphQL comments before + // scanning for the `mutation` keyword to defeat + // `\n# comment\nmutation { … }` style smuggling. + try { + const parsed = JSON.parse(body) as { query?: unknown } + if (typeof parsed.query === "string" && isLikelyMutation(parsed.query)) { + return NextResponse.json( + { error: "Mutations are not allowed through this proxy" }, + { status: 400 }, + ) + } + } catch { + // Not JSON — let the upstream return its native parse error. The + // body-size cap already bounds the work. + } + // Inherit the client's abort signal where possible so a navigation // away cancels the upstream fetch instead of leaving it dangling, // and add a hard timeout in case the indexer hangs. @@ -136,3 +174,26 @@ export async function POST(request: NextRequest) { clearTimeout(timeoutId) } } + +/** True when the GraphQL query string starts (ignoring leading whitespace + * and `#` line-comments) with the `mutation` keyword. */ +function isLikelyMutation(query: string): boolean { + // Strip leading whitespace and `# …\n` comments. Bounded loop in + // case of pathological input (e.g. one-megabyte comment block — + // already prevented by MAX_BODY_SIZE but belt + braces). + let i = 0 + const n = query.length + let guard = 0 + while (i < n && guard < 4096) { + const ch = query.charCodeAt(i) + if (ch === 32 || ch === 9 || ch === 10 || ch === 13) { + i++ + } else if (ch === 35 /* # */) { + while (i < n && query.charCodeAt(i) !== 10) i++ + } else { + break + } + guard++ + } + return query.slice(i, i + 8).toLowerCase().startsWith("mutation") +} diff --git a/src/app/api/resolve-did/route.ts b/src/app/api/resolve-did/route.ts index 2198b2cf..de3e3032 100644 --- a/src/app/api/resolve-did/route.ts +++ b/src/app/api/resolve-did/route.ts @@ -25,6 +25,9 @@ function extractBlobLink(ref: BlobLike["ref"]): string | null { type CertsProfileValue = { displayName?: string description?: string + pronouns?: string + website?: string + createdAt?: string avatar?: { $type?: string; uri?: string; image?: BlobLike } | undefined banner?: { $type?: string; uri?: string; image?: BlobLike } | undefined } @@ -69,8 +72,11 @@ function resolveCertsField( async function getCertsProfile(did: string): Promise<{ displayName?: string description?: string + pronouns?: string + website?: string avatarUrl: string | null bannerUrl: string | null + createdAt?: string } | null> { try { const targetPds = await resolvePdsUrl(did) @@ -92,8 +98,11 @@ async function getCertsProfile(did: string): Promise<{ return { displayName: value.displayName, description: value.description, + pronouns: value.pronouns, + website: value.website, avatarUrl: resolveCertsField(value.avatar, did), bannerUrl: resolveCertsField(value.banner, did), + createdAt: value.createdAt, } } catch { return null @@ -188,8 +197,11 @@ export async function GET(request: NextRequest) { const displayName = certs?.displayName || bsky?.displayName || undefined const description = certs?.description || bsky?.description || undefined + const pronouns = certs?.pronouns + const website = certs?.website const avatar = certs?.avatarUrl ?? bsky?.avatar ?? undefined const banner = certs?.bannerUrl ?? bsky?.banner ?? undefined + const createdAt = certs?.createdAt // Own DID: short 10s cache so repeat navigations (clicking your // own profile from the nav) feel instant without a network hit, @@ -211,8 +223,11 @@ export async function GET(request: NextRequest) { handle: handle || did, displayName, description, + pronouns, + website, avatar, banner, + createdAt, }, { headers: { "Cache-Control": cacheControl } } ) diff --git a/src/app/api/xrpc/[...method]/route.ts b/src/app/api/xrpc/[...method]/route.ts index 1485c22b..7570cffb 100644 --- a/src/app/api/xrpc/[...method]/route.ts +++ b/src/app/api/xrpc/[...method]/route.ts @@ -26,6 +26,7 @@ const ALLOWED_WRITE_COLLECTIONS = [ "app.certified.actor.profile", "app.certified.actor.membership", "app.certified.actor.organization", + "app.certified.location", "app.certified.temp.graph.endorsement", // Badge lexicons (issue #65 tracks indexer-side support). `definition` // is written once per user the first time they endorse; `award` is the @@ -34,6 +35,10 @@ const ALLOWED_WRITE_COLLECTIONS = [ "app.certified.badge.definition", "app.certified.badge.award", "app.certified.badge.response", + // Certified social graph — follow records live on the viewer's PDS; + // the followers view is served by the magic-indexer via the + // `appCertifiedGraphFollow` connection. + "app.certified.graph.follow", "org.hypercerts.claim.activity", ] diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index b3cbc323..b57ba452 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -123,7 +123,7 @@ export default function CreatePage() { return (
-

Create Activity

+

Create Cert

diff --git a/src/app/globals.css b/src/app/globals.css index 600df7cf..d15b92dc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,6 +8,17 @@ @import "./styles/profile.css"; @import "./styles/pages.css"; @import "./styles/notifications.css"; +@import "./styles/cert-detail.css"; +@import "./styles/profile-endorsements.css"; +@import "./styles/profile-edit.css"; +@import "./styles/profile-inline-edit.css"; +@import "./styles/profile-projects.css"; +@import "./styles/social-graph-sync.css"; +@import "./styles/project-detail.css"; +@import "./styles/profile-groups.css"; +@import "./styles/smart-link.css"; +@import "./styles/settings-page.css"; +@import "./styles/leaflet.css"; @tailwind base; @tailwind components; diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index 5272ce14..1c7309d9 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -251,7 +251,7 @@ export default function GroupsPage() { {leaveOrg && (
setLeaveOrg(null)}> -
e.stopPropagation()} style={{ maxWidth: 420 }}> +
e.stopPropagation()} style={{ maxWidth: 420 }}>
Leave Group - ))} + {/* Mobile-only identity block. Hidden on desktop where the sidebar + carries the identity. The desktop top bar's row 2 is the only + tab strip; there is no in-page tab strip. */} +
+
-
- {activeTab === "activities" && ( -
- -
- )} - {activeTab === "groups" && ( -
- -
- )} - {activeTab === "endorsements" && ( -
- + {editing ? ( + + ) : null} + + {activeTab === "settings" && isViewerThisEntity ? ( + // Settings tab swaps the entire profile-page two-pane layout + // out for the settings panel's own menu+sections two-pane + // layout — same 296px slim rail, but the left pane carries + // settings categories (Username / Email / Password / + // Appearance) instead of the profile identity sidebar. + + ) : ( +
+ + +
+ {activeTab === "overview" && ( +
+ +
+ )} + {activeTab === "about" ? ( +
+ {editing && sidebarIsOrg ? ( + + handleDraftChange("longDescription", next) + } + placeholder="A longer, multi-line description of this organization." + ariaLabel="Long description" + did={did} + onImageUpload={handleLongDescImageUpload} + /> + ) : displayLongDescription ? ( + + ) : isViewerThisEntity ? ( + /* Empty About tab, but the viewer is signed in as + this entity — show the prompt to click "Edit + profile". Foreign viewers don't reach this branch + because the tab gate hides the About tab when + there's no content for them. */ + + ) : null} +
+ ) : null} + {activeTab === "certs" && ( +
+ +
+ )} + {activeTab === "projects" && ( +
+ +
+ )} + {activeTab === "groups" && ( +
+ +
+ )} + {activeTab === "endorsements" && ( +
+ +
+ )} + {activeTab === "followers" && did && ( +
+ +
+ )}
- )} -
+
+ )}
) } diff --git a/src/app/project/[did]/[rkey]/page.tsx b/src/app/project/[did]/[rkey]/page.tsx new file mode 100644 index 00000000..cb0b92ac --- /dev/null +++ b/src/app/project/[did]/[rkey]/page.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useMemo } from "react" +import { useParams } from "next/navigation" +import { usePageTitle, usePageTitleBreadcrumb } from "@/lib/navbar-context" +import { useProject } from "@/hooks/use-project" +import { useAuthorInfo } from "@/hooks/use-author-info" +import ProjectDetail from "@/components/project/project-detail" +import LoadingSpinner from "@/components/ui/loading-spinner" + +export default function ProjectDetailPage() { + // Plain-string fallback while author/project data is still resolving. + // The breadcrumb below takes precedence once both pieces are available. + usePageTitle("Project") + + const params = useParams() + const did = useMemo(() => { + const raw = params.did + if (typeof raw !== "string") return null + return decodeURIComponent(raw) + }, [params.did]) + const rkey = useMemo(() => { + const raw = params.rkey + if (typeof raw !== "string") return null + return decodeURIComponent(raw) + }, [params.rkey]) + + const { project, isLoading, error } = useProject(did, rkey) + const { info: authorInfo } = useAuthorInfo(did) + + const handle = authorInfo?.handle ?? null + const projectTitle = + (typeof project?.value.title === "string" && project.value.title) || + (typeof project?.value.name === "string" && project.value.name) || + null + usePageTitleBreadcrumb( + handle && projectTitle && did && rkey + ? { + left: { + text: handle, + href: `/profile/${encodeURIComponent(handle)}`, + }, + right: { + text: projectTitle, + href: `/project/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`, + }, + } + : null, + ) + + if (isLoading) { + return ( +
+
+ +
+
+ ) + } + + if (error || !project) { + return ( +
+
+

+ {error || "Project not found"} +

+

+ This project may have been deleted or is on a PDS we can’t + reach. +

+
+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/src/app/project/new/page.tsx b/src/app/project/new/page.tsx new file mode 100644 index 00000000..da84ef0c --- /dev/null +++ b/src/app/project/new/page.tsx @@ -0,0 +1,38 @@ +"use client" + +import Link from "next/link" +import { ArrowLeft, FolderGit2 } from "lucide-react" +import Button from "@/components/ui/button" +import EmptyState from "@/components/ui/empty-state" +import { usePageTitle } from "@/lib/navbar-context" + +/** + * `/project/new` — placeholder destination for the "Create new + * project" CTA on the profile Projects tab. The project editor + * itself is still being built; surfacing a friendly placeholder + * here keeps the navigation flow consistent and lets us validate + * the entry point without blocking on the editor. + * + * Lives at a static segment under `/project/`. The dynamic + * `[did]/[rkey]` route below requires two path segments, so this + * page never collides. + */ +export default function CreateProjectPlaceholderPage() { + usePageTitle("Create project") + return ( +
+ + + + + +
+ ) +} diff --git a/src/app/settings/edit-profile/page.tsx b/src/app/settings/edit-profile/page.tsx index 0dd826aa..a002a79f 100644 --- a/src/app/settings/edit-profile/page.tsx +++ b/src/app/settings/edit-profile/page.tsx @@ -1,25 +1,130 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useAuth } from "@/lib/auth/auth-context"; import { useProfile } from "@/hooks/use-profile"; -import { usePageTitle } from "@/lib/navbar-context"; +import { useSession } from "@/hooks/use-session"; +import { + usePageTitle, + usePageTitleBreadcrumb, +} from "@/lib/navbar-context"; import { putProfile, uploadAvatar, uploadBanner } from "@/lib/atproto/profile"; +import { authFetch } from "@/lib/auth/fetch"; +import { extractError } from "@/lib/utils/api"; +import { ORG_MARKER_COLLECTION } from "@/lib/groups/constants"; import LoadingSpinner from "@/components/ui/loading-spinner"; import ProfileEditForm from "@/components/profile/profile-edit-form"; import type { CertifiedProfile } from "@/lib/atproto/types"; +import type { GroupMetadata, OrgUrlItem } from "@/lib/groups/types"; + +/** + * Fetch the current user's `app.certified.actor.organization` record + * directly from their PDS via the xrpc proxy. Returns null when the + * record doesn't exist — that's the "this account is not an org" case. + * + * TODO(org-urls): editing an org's URLs from the *personal* edit-profile + * page only works for accounts whose own DID carries the org marker. + * Group accounts whose DID is owned by the group service have their + * metadata edited at /groups/[groupDid]/edit-profile and persisted via + * the group-service-proxied PUT — both paths should converge once we + * unify the org marker model. + */ +async function fetchOwnOrgMarker( + did: string, + signal?: AbortSignal, +): Promise { + const res = await authFetch( + `/api/xrpc/com/atproto/repo/getRecord?repo=${encodeURIComponent( + did, + )}&collection=${encodeURIComponent(ORG_MARKER_COLLECTION)}&rkey=self`, + { signal }, + ); + if (!res.ok) { + if (res.status === 400 || res.status === 404) return null; + return null; + } + const data = (await res.json()) as { value?: GroupMetadata }; + return data.value ?? null; +} + +async function putOwnOrgMarker( + did: string, + record: GroupMetadata, +): Promise { + const res = await authFetch("/api/xrpc/com/atproto/repo/putRecord", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo: did, + collection: ORG_MARKER_COLLECTION, + rkey: "self", + record: { + ...record, + $type: ORG_MARKER_COLLECTION, + }, + }), + }); + if (!res.ok) { + throw new Error(await extractError(res, "Failed to save organization URLs")); + } +} export default function EditProfilePage() { - usePageTitle("Edit profile"); const { isAuthenticated, did } = useAuth(); + const { handle } = useSession(); const { profile, isLoading, avatarUrl, bannerUrl } = useProfile(); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const handleAvatarUpload = (file: File) => uploadAvatar(file); - const handleBannerUpload = (file: File) => uploadBanner(file); + const [orgMarker, setOrgMarker] = useState(null); + const [isOrg, setIsOrg] = useState(false); + const [orgLoaded, setOrgLoaded] = useState(false); - const handleSave = async (updatedProfile: CertifiedProfile) => { + // Drive the navbar breadcrumb. When we know the handle, render + // `@handle / Edit profile`; otherwise fall through to a plain title. + // The right segment's href is the current page itself so the breadcrumb + // text is a clickable no-op (matches the GitHub `owner / repo` pattern). + usePageTitle("Edit profile"); + usePageTitleBreadcrumb( + handle + ? { + left: { text: handle, href: `/profile/${handle}` }, + right: { + text: "Edit profile", + href: "/settings/edit-profile", + }, + } + : null, + ); + + useEffect(() => { + if (!did) { + setOrgMarker(null); + setIsOrg(false); + setOrgLoaded(true); + return; + } + const controller = new AbortController(); + fetchOwnOrgMarker(did, controller.signal) + .then((record) => { + if (controller.signal.aborted) return; + setOrgMarker(record); + setIsOrg(record !== null); + }) + .catch(() => undefined) + .finally(() => { + if (!controller.signal.aborted) setOrgLoaded(true); + }); + return () => controller.abort(); + }, [did]); + + const handleSave = async ({ + profile: updatedProfile, + orgUrls, + }: { + profile: CertifiedProfile; + orgUrls: OrgUrlItem[] | null; + }) => { if (!isAuthenticated || !did) { setSaveError("Not authenticated"); return; @@ -27,60 +132,77 @@ export default function EditProfilePage() { try { setIsSaving(true); setSaveError(null); + + // Save profile first (text + images). Org URLs are written to a + // separate record (`app.certified.actor.organization`) and only + // when the account already carries the org marker. await putProfile(did, updatedProfile); + + if (isOrg && orgUrls !== null) { + const nextMarker: GroupMetadata = { + ...(orgMarker ?? { createdAt: new Date().toISOString() }), + urls: orgUrls.length > 0 ? orgUrls : undefined, + }; + await putOwnOrgMarker(did, nextMarker); + } + // Defensive: evict any browser-cached resolve-did response for // this DID so the next page load sees the fresh record even if // a stale response is sitting in the disk cache from before we - // switched same-DID responses to no-store. `cache: "reload"` - // forces the browser to refetch and store the new response. + // switched same-DID responses to no-store. await fetch(`/api/resolve-did?did=${encodeURIComponent(did)}`, { cache: "reload", credentials: "include", }).catch(() => undefined); + // Hard reload to /profile so every component (profile context, // navbar avatar, useUserProfile on the destination page, blob // URLs) remounts with fresh data. A client-side push here used // to leave the navbar showing the old avatar for ~30s. window.location.assign("/profile"); - // Don't reset isSaving — the page is unmounting on navigate. return; } catch (error) { console.error("Failed to save profile:", error); - setSaveError(error instanceof Error ? error.message : "Failed to save profile"); + setSaveError( + error instanceof Error ? error.message : "Failed to save profile", + ); setIsSaving(false); } }; - const currentAvatarUrl = avatarUrl; - const currentBannerUrl = bannerUrl; - const fallbackInitials = profile?.displayName ? profile.displayName.slice(0, 2) - : did ? did.slice(4, 6) : "?"; + : did + ? did.slice(4, 6) + : "?"; - return ( -
-
-
- {isLoading ? ( -
- -
- ) : ( - - )} -
+ const initialOrgUrls = orgMarker?.urls ?? []; + + // Render directly into `.app-shell__content` (the 600px reading + // column). The form supplies all its own structure — no surrounding + // dashboard chrome is needed. + if (isLoading || !orgLoaded) { + return ( +
+
-
+ ); + } + + return ( + uploadAvatar(file)} + onBannerUpload={(file) => uploadBanner(file)} + currentAvatarUrl={avatarUrl} + currentBannerUrl={bannerUrl} + fallbackInitials={fallbackInitials} + /> ); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 64e46268..a1068a4d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,52 +1,26 @@ "use client"; -import React from "react"; -import dynamic from "next/dynamic"; -import { useAuth } from "@/lib/auth/auth-context"; import { useSession } from "@/hooks/use-session"; -import { useOrg } from "@/lib/groups/org-context"; -import { usePageTitle } from "@/lib/navbar-context"; -import OrgSettings from "@/components/groups/org-settings"; -import ThemeToggle from "@/components/ui/theme-toggle"; - -const UsernameCard = dynamic(() => import("@/components/dashboard/username-card")); -const EmailSection = dynamic(() => import("@/components/account/email-section")); -const PasswordSection = dynamic(() => import("@/components/account/password-section")); - +import { + usePageTitle, + usePageTitleBreadcrumb, +} from "@/lib/navbar-context"; +import SettingsPanel from "@/components/settings/settings-panel"; + +/** + * Standalone `/settings` route — preserved as a deep-link target. + * + * The same panel is rendered from the profile page at + * `/profile/?tab=settings`, where it sits alongside the + * profile sidebar. Both paths share the same `` + * component so the scroll-spy + deep-link `#section` behaviour is + * identical regardless of which URL the user lands on. + */ export default function SettingsPage() { + const { handle } = useSession(); usePageTitle("Settings"); - const { did, pdsUrl } = useAuth(); - const { handle, email } = useSession(); - const { activeOrg } = useOrg(); - - // When acting as a group, show org settings - if (activeOrg) { - return ; - } - - return ( -
-
-
- {/* Appearance card */} -
-

Appearance

-

- Choose how Certified looks to you. Select a light or dark theme, or follow your system preference. -

- -
- - {/* Username card */} - - - {/* Email section */} - - - {/* Password card */} - -
-
-
+ usePageTitleBreadcrumb( + handle ? { left: { text: handle, href: `/profile/${handle}` } } : null, ); + return ; } diff --git a/src/app/styles/cert-detail.css b/src/app/styles/cert-detail.css new file mode 100644 index 00000000..b8559cb8 --- /dev/null +++ b/src/app/styles/cert-detail.css @@ -0,0 +1,928 @@ +/* ========== Cert detail page ========== + + The cert page shares its outer layout with the profile page: the + article root uses `.page-layout` (2-column grid: 296px sidebar + + fluid main) and the right pane uses `.page-layout__main`. + This keeps the page chrome (sidebar slot, main pane, gutters) and + the inline-edit banner visually identical to the profile page. The + cert-specific content classes (`.cert-detail__aside`, + `.cert-detail__image`, `.cert-detail__meta`, etc.) carry only the + cert pane's interior styling. + + `.cert-detail--wide` is the opt-in marker on the article root. The + `:has()` rule below mirrors `.app-shell--fullbleed` so the shell's + content slot caps at 1280px with no lateral padding — same as the + profile page's full-bleed shell — and the banner renders at the + same width on both pages. We keep the shell's default padding-top + though, because the cert page does NOT use an overlay navbar; its + content must start below the solid top bar. */ + +.cert-detail-page { + padding-bottom: 64px; +} + +.app-shell:has(.cert-detail--wide) .app-shell__content { + /* Zero ALL `.app-shell__content` padding so the cert page renders + edge-to-edge like the profile page's `.app-shell--fullbleed`. + Top spacing for the read-only view comes from the inner + `.page-layout` (which has padding: 24px on desktop); top spacing + in edit mode comes from the edit banner's own margin-top. */ + padding: 0; +} + +@media (min-width: 800px) { + .app-shell:has(.cert-detail--wide) .app-shell__content { + max-width: 1280px; + } +} + +/* ---------- Loading + error ---------- */ + +.cert-detail__loading, +.cert-detail__error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 96px 24px; + text-align: center; +} + +.cert-detail__error-title { + font-size: 1rem; + font-weight: 600; + color: var(--fg-primary); + margin: 0 0 6px; +} + +.cert-detail__error-desc { + font-size: 0.875rem; + color: var(--fg-muted); + margin: 0; + max-width: 32ch; + line-height: 1.5; +} + +/* ---------- Left pane (slim aside) ---------- */ + +.cert-detail__aside { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.cert-detail__image { + width: 100%; + aspect-ratio: 1 / 1; + overflow: hidden; + border-radius: 8px; + background: var(--bg-sunken); + border: 1px solid var(--border-subtle); + /* Center the placeholder icon (Award) when there's no . The + icon used to sit in the top-left because the box had default + block layout. The img child fills 100% / 100% so flex centering + doesn't visibly change it. */ + display: flex; + align-items: center; + justify-content: center; + /* Inline-edit chrome anchors absolute children (Edit pill, the + dashed outline below). Kept here rather than in a duplicate + rule lower in the file. */ + position: relative; +} + +.cert-detail__image-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.cert-detail__image--placeholder { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + var(--bg-sunken) 0%, + var(--overlay-weak) 100% + ); +} + +.cert-detail__image-placeholder-icon { + color: var(--fg-muted); + opacity: 0.55; +} + +/* Inline-edit chrome ---------------------------------------------- */ + +.cert-detail__image--editing { + outline: 1.5px dashed var(--border-hover); + outline-offset: -2px; +} + +/* Floating Camera pill for cert image upload. Mirrors the + `.profile-sidebar__avatar-edit-btn` / `.profile-banner-upload__btn` + visual treatment used by other upload affordances. */ +.cert-detail__image-edit-btn { + position: absolute; + bottom: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(17, 17, 17, 0.78); + color: var(--color-white); + border: none; + border-radius: 999px; + padding: 6px 12px; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: background var(--transition-fast); + z-index: 1; +} + +.cert-detail__image-edit-btn:hover { + background: rgba(17, 17, 17, 0.92); +} + +.cert-detail__image-edit-btn:disabled { + cursor: progress; + opacity: 0.9; +} + +/* Title and short-description inputs replace the static h1 / p + while editing. Border treatment matches the profile-page inline + edit inputs so the two surfaces feel like one editor family. */ +.cert-detail__title-input { + flex: 1 1 auto; + min-width: 0; + font-family: var(--font-headline), "Noto Serif", serif; + font-size: 1.875rem; + font-weight: 700; + color: var(--fg-primary); + line-height: 1.15; + letter-spacing: -0.015em; + border: 1.5px solid var(--border-hover); + border-radius: 6px; + padding: 4px 10px; + background: var(--bg-elevated); +} + +.cert-detail__title-input:focus { + outline: none; + border-color: var(--fg-primary); + box-shadow: 0 0 0 2px var(--overlay-weak); +} + +.cert-detail__short-desc-input { + width: 100%; + font: inherit; + font-size: 1rem; + line-height: 1.6; + color: var(--fg-secondary); + border: 1.5px solid var(--border-hover); + border-radius: 6px; + padding: 10px 12px; + background: var(--bg-elevated); + resize: vertical; + min-height: 72px; +} + +.cert-detail__short-desc-input:focus { + outline: none; + border-color: var(--fg-primary); + box-shadow: 0 0 0 2px var(--overlay-weak); +} + +/* (Older byline used to sit in the aside — it moved into the main pane + under the title; see `.cert-detail__headline-byline` below.) */ + +/* ---------- Project section (left pane) ---------- + + Renders only when this cert is in at least one project collection. + A thin stack of rows: thumbnail + name + optional short description. + Each row is its own link target. */ + +.cert-detail__projects { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.cert-detail__section-title--aside { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + margin: 0; +} + +.cert-detail__projects-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cert-detail__project { + display: flex; + min-width: 0; +} + +.cert-detail__project-link { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + padding: 6px 8px; + border-radius: 6px; + text-decoration: none; + color: inherit; +} + +.cert-detail__project-link:hover, +.cert-detail__project-link:focus-visible { + background: var(--overlay-weak); +} + +.cert-detail__project-link--static { + cursor: default; +} + +.cert-detail__project-link--static:hover { + background: transparent; +} + +.cert-detail__project-thumb-wrap { + flex-shrink: 0; +} + +.cert-detail__project-thumb { + width: 32px; + height: 32px; + border-radius: 6px; + overflow: hidden; + object-fit: cover; + display: block; + background: var(--bg-sunken); + border: 1px solid var(--border-subtle); +} + +.cert-detail__project-thumb--placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-muted); +} + +.cert-detail__project-meta { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + flex: 1; +} + +.cert-detail__project-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cert-detail__project-desc { + font-size: 0.75rem; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Main pane ---------- */ + +.cert-detail__main { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +.cert-detail__headline { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cert-detail__title { + font-family: var(--font-headline), "Noto Serif", serif; + font-size: 1.875rem; + font-weight: 700; + color: var(--fg-primary); + line-height: 1.15; + letter-spacing: -0.015em; + margin: 0; + word-break: break-word; +} + +.cert-detail__short-desc { + font-size: 1rem; + line-height: 1.6; + color: var(--fg-secondary); + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Title row — keeps the cert title on the left and floats the + creator-only Edit button to the far right. Wrap on small screens + so the button drops below the title rather than colliding. */ +.cert-detail__title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.cert-detail__title-row .cert-detail__title { + flex: 1 1 auto; + min-width: 0; +} + +.cert-detail__edit-btn { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + height: 32px; + padding: 0 12px; + border-radius: var(--radius); + border: 1px solid var(--border-default); + background: var(--bg-elevated); + color: var(--fg-primary); + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.cert-detail__edit-btn:hover { + background: var(--bg-sunken); + border-color: var(--border-hover); +} + +.cert-detail__edit-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* Inline "more" link — quiet style; sits at the end of the short + description as natural prose continuation, no button chrome. */ +.cert-detail__more-link { + color: var(--fg-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.cert-detail__more-link:hover { + text-decoration-thickness: 2px; +} + +.cert-detail__more-link:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 2px; +} + +/* ---------- Headline byline ---------- + + Sits directly under the title: + + by + @ + + The avatar + name+handle stack is one focusable link. The "by" + separator is a static span, and the date is on the far left. Wraps + gracefully on narrow screens. */ + +.cert-detail__headline-byline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 0.875rem; + color: var(--fg-secondary); +} + +.cert-detail__headline-date { + color: var(--fg-secondary); + font-feature-settings: 'tnum' 1; +} + +.cert-detail__headline-label, +.cert-detail__headline-by { + color: var(--fg-muted); +} + +.cert-detail__headline-author { + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: inherit; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 6px; +} + +.cert-detail__headline-author:hover, +.cert-detail__headline-author:focus-visible { + background: var(--overlay-weak); +} + +/* The shared Avatar component renders at 32 px in "sm" size. The cert + detail byline wants a ~24 px footprint; we scale it down via a CSS + transform on the immediate child without forcing a new Avatar size + prop. */ +.cert-detail__headline-author > div:first-child { + width: 24px; + height: 24px; +} + +.cert-detail__headline-author-meta { + display: inline-flex; + flex-direction: column; + line-height: 1.15; + min-width: 0; +} + +.cert-detail__headline-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-primary); + white-space: nowrap; +} + +.cert-detail__headline-handle { + font-size: 0.75rem; + color: var(--fg-muted); + white-space: nowrap; +} + +/* Skeletons */ +.cert-detail__headline-author--skeleton { + cursor: default; +} + +.cert-detail__headline-avatar-skel { + width: 24px; + height: 24px; + border-radius: 999px; + background: var(--overlay-weak); +} + +.cert-detail__headline-name-skel { + width: 90px; + height: 10px; + border-radius: 4px; + background: var(--overlay-weak); + margin-bottom: 4px; + display: block; +} + +.cert-detail__headline-handle-skel { + width: 60px; + height: 9px; + border-radius: 4px; + background: var(--overlay-weak); + display: block; +} + +/* ---------- Two-column body ---------- + + Single stacked column under 720px, two columns above. The sidebar + sits at a fixed 260px so the main column can fluidly absorb extra + width on wider viewports. */ + +/* ---------- Metadata block (left pane) ---------- + + Stacked label/value pairs under the byline. Small uppercase labels + above each value, mirroring the profile sidebar's details list. */ + +.cert-detail__meta { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 0; +} + +.cert-detail__meta-row { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.cert-detail__meta-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + display: inline-flex; + align-items: center; + gap: 5px; +} + +.cert-detail__meta-value { + font-size: 0.875rem; + color: var(--fg-primary); + line-height: 1.4; + word-break: break-word; +} + +.cert-detail__meta-aux { + color: var(--fg-muted); + font-size: 0.75rem; + margin-left: 4px; +} + +.cert-detail__uri { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + color: var(--fg-secondary); + word-break: break-all; +} + +/* ---------- Sections (contributors / locations / description) ---------- */ + +.cert-detail__section { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 24px; + margin-top: 4px; +} + +.cert-detail__main > .cert-detail__section:first-child { + padding-top: 0; + margin-top: 0; +} + +.cert-detail__main > .cert-detail__section + .cert-detail__section { + border-top: 1px solid var(--border-subtle); +} + +.cert-detail__section-header { + display: flex; + align-items: baseline; + gap: 12px; +} + +/* Title + count cluster on the left; "See all" pushes to the + right via auto-margin so the layout stays clean when the count + pill is hidden. */ +.cert-detail__section-header > .cert-detail__section-title { + margin-right: 0; +} + +.cert-detail__section-see-all { + margin-left: auto; + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-secondary); + text-decoration: none; + transition: color var(--transition-fast); +} + +.cert-detail__section-see-all:hover { + color: var(--fg-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Centered "X contributors — see all" link at the bottom of the + Overview tab's Contributors preview. Lifts the affordance out + of the section header (where it competed with the title) and + makes it act as a clear "load more" CTA after the list. */ +.cert-detail__section-see-all-footer { + display: block; + margin: 8px auto 0; + text-align: center; + font-size: 0.8125rem; + font-weight: 500; + color: var(--fg-secondary); + text-decoration: none; + padding: 8px 12px; + border-radius: 6px; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.cert-detail__section-see-all-footer:hover { + background: var(--overlay-weak); + color: var(--fg-primary); +} + +.cert-detail__section-see-all-footer:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.cert-detail__section-title { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + margin: 0; +} + +.cert-detail__section-count { + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-muted); + padding: 1px 8px; + border-radius: 999px; + background: var(--overlay-weak); + font-feature-settings: 'tnum' 1; +} + +/* ---------- Contributors list ---------- + + Single-column stack — lives inside the sidebar so it never gets the + wide-viewport 2-col upgrade the old layout used. */ + +.cert-detail__contributors { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cert-detail__contributor { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 6px; + min-width: 0; +} + +.cert-detail__contributor-link { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + text-decoration: none; + color: inherit; +} + +/* Subtle pill background on hover — matches profile-certs grid */ +.cert-detail__contributor:has(.cert-detail__contributor-link:hover), +.cert-detail__contributor:has(.cert-detail__contributor-link:focus-visible) { + background: var(--overlay-weak); +} + +.cert-detail__contributor-link--static { + cursor: default; +} + +.cert-detail__contributor-meta { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.2; + flex: 1; +} + +.cert-detail__contributor-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cert-detail__contributor-handle { + font-size: 0.75rem; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cert-detail__contributor-role { + font-size: 0.75rem; + color: var(--fg-muted); + font-weight: 400; +} + +.cert-detail__contributor-weight { + font-size: 0.6875rem; + font-weight: 600; + color: var(--fg-secondary); + padding: 1px 7px; + border-radius: 999px; + background: var(--overlay-weak); + font-feature-settings: 'tnum' 1; + flex-shrink: 0; +} + +/* Skeleton */ +.cert-detail__contributor--skeleton { + pointer-events: none; +} + +.cert-detail__contributor-avatar-skel { + width: 28px; + height: 28px; + border-radius: 999px; + background: var(--overlay-weak); + flex-shrink: 0; +} + +.cert-detail__contributor-name-skel { + width: 100px; + height: 10px; + border-radius: 4px; + background: var(--overlay-weak); + margin-bottom: 4px; +} + +.cert-detail__contributor-handle-skel { + width: 64px; + height: 9px; + border-radius: 4px; + background: var(--overlay-weak); +} + +/* ---------- Long-form description ---------- */ + +.cert-detail__description { + font-size: 0.9375rem; + line-height: 1.65; + color: var(--fg-secondary); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +/* "Show full description" disclosure — sits under shortDescription and + reveals the rich `value.description` content.
-based so it + works without JS. */ + +.cert-detail__full-disclosure { + margin: 0; + padding: 0; + border: 0; +} + +.cert-detail__full-disclosure summary { + font-size: 0.8125rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + list-style: none; + padding: 6px 0; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.cert-detail__full-disclosure summary::-webkit-details-marker { + display: none; +} + +.cert-detail__full-disclosure summary::before { + content: "▸"; + font-size: 0.625rem; + color: var(--fg-muted); + transition: transform 0.15s ease; + display: inline-block; +} + +.cert-detail__full-disclosure[open] summary::before { + transform: rotate(90deg); +} + +.cert-detail__full-disclosure summary:hover, +.cert-detail__full-disclosure[open] summary { + color: var(--fg-primary); +} + +.cert-detail__full-body { + padding-top: 8px; +} + +/* Cert-detail-specific spacing tweaks layered on top of the neutral + `.leaflet-doc` base styles (see styles/leaflet.css). The renderer + emits `leaflet-doc__*` class names everywhere; this surface just + adjusts gap + spacing to match the rest of the cert detail page. */ +.cert-detail .leaflet-doc { + gap: 14px; + font-size: 0.9375rem; + line-height: 1.65; + color: var(--fg-secondary); + word-break: break-word; +} + +/* ---------- Locations map ---------- */ + +.cert-detail__map-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cert-detail__map { + width: 100%; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-subtle); + background: var(--bg-sunken); +} + +.cert-detail__map--skeleton { + height: 320px; + background: linear-gradient( + 135deg, + var(--bg-sunken) 0%, + var(--overlay-weak) 100% + ); +} + +.cert-detail__map--empty { + height: 120px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--fg-muted); + font-size: 0.8125rem; +} + +.cert-detail__map--empty p { + margin: 0; +} + +.cert-detail__map-other { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8125rem; + color: var(--fg-secondary); +} + +.cert-detail__map-other-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 6px; + background: var(--overlay-weak); + min-width: 0; +} + +.cert-detail__map-other-name { + font-weight: 500; + color: var(--fg-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cert-detail__map-other-detail { + color: var(--fg-muted); + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Mobile tweaks ---------- */ + +@media (max-width: 520px) { + .cert-detail__title { + font-size: 1.5rem; + } +} diff --git a/src/app/styles/components.css b/src/app/styles/components.css index 59db276e..3b6c414b 100644 --- a/src/app/styles/components.css +++ b/src/app/styles/components.css @@ -109,6 +109,25 @@ dialog.signin-modal::backdrop { background: var(--navy-overlay-70); } +/* App-wide modal shape. Add this class ALONGSIDE `.signin-modal` on + any non-sign-in dialog (``) + so the radius drops from the sign-in surface's intentional 20px + down to the design system's 2px (`var(--radius)`). Padding is also + trimmed since dense form/list modals don't need the sign-in + surface's hero spacing. See DESIGN.md §11 — Modals. */ +dialog.signin-modal.app-modal { + border-radius: var(--radius); + padding: 0; +} + +dialog.signin-modal.app-modal .signin-modal__header { + padding: 16px 20px 12px; +} + +dialog.signin-modal.app-modal .signin-modal__body { + padding: 0 20px 20px; +} + .signin-modal__close { position: absolute; top: -8px; diff --git a/src/app/styles/feed.css b/src/app/styles/feed.css index b7e37758..1b692d19 100644 --- a/src/app/styles/feed.css +++ b/src/app/styles/feed.css @@ -476,6 +476,23 @@ display: block; } +.feed-card__image-wrap--placeholder { + display: flex; + align-items: center; + justify-content: center; + background: + linear-gradient( + 135deg, + var(--bg-sunken) 0%, + var(--overlay-weak) 100% + ); +} + +.feed-card__image-placeholder-icon { + color: var(--fg-muted); + opacity: 0.6; +} + .feed-card__title { font-family: var(--font-headline), "Noto Serif", serif; font-size: 1.125rem; @@ -1881,7 +1898,7 @@ .endorsement-multi-row__error { font-size: 0.75rem; - color: var(--danger, #d44); + color: var(--color-error); margin-top: 2px; } @@ -1907,7 +1924,7 @@ } .endorsement-multi-row__status--error { - background: var(--danger, #d44); + background: var(--color-error); color: white; } @@ -2011,7 +2028,7 @@ .profile-endorsements__endorse-error { display: block; font-size: 0.75rem; - color: var(--danger, #d44); + color: var(--color-error); margin-top: 4px; } diff --git a/src/app/styles/layout.css b/src/app/styles/layout.css index 09c026c3..96c974dd 100644 --- a/src/app/styles/layout.css +++ b/src/app/styles/layout.css @@ -260,7 +260,7 @@ html[data-theme="dark"] .signin-mark__img--light { justify-self: center; font-family: var(--font-inter), system-ui, sans-serif; font-size: 0.9375rem; - font-weight: 600; + font-weight: 450; color: var(--fg-primary); letter-spacing: -0.01em; white-space: nowrap; @@ -269,6 +269,24 @@ html[data-theme="dark"] .signin-mark__img--light { max-width: 60vw; } +.navbar__title-part { + color: inherit; + text-decoration: none; + padding: 3px 6px; + margin: 0 -2px; + border-radius: 6px; + transition: background var(--transition-fast); +} + +.navbar__title-part:hover { + background: var(--overlay-weak); +} + +.navbar__title-sep { + color: var(--fg-muted); + font-weight: 400; +} + .navbar__logo-img { height: 28px; width: auto; @@ -703,34 +721,20 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name { } } -/* Desktop cascade: left rail at >=800; left + right at >=1100; full - widths at >=1300. Numbers mirror --bp-* tokens in tokens.css. - At <800px the grid is `display: block` so children stack as before; - the rails carry `display: none` below their breakpoints. +/* Desktop cascade (Overview redesign): both rails retired. The grid is + a single centered column. Non-profile pages keep a narrow reading + column (.app-shell__content max-width below). Profile pages widen + their own container via `.app-shell--fullbleed` to ~1280px so the + GitHub-style two-column Overview fits. - Cells use fixed widths (NOT 1fr) so the center column genuinely caps - at 600px instead of stretching across the leftover viewport space. - The grid as a whole is then centered in the wrapper via - `justify-content: center`; outer gutters at wider viewports are - passive whitespace per DESIGN.md. */ + At <800px the grid is `display: block` so children stack as before. */ @media (min-width: 800px) { .app-shell__grid { display: grid; - grid-template-columns: 86px minmax(0, 600px); - column-gap: 0; + grid-template-columns: minmax(0, 1280px); justify-content: center; } } -@media (min-width: 1100px) { - .app-shell__grid { - grid-template-columns: 86px minmax(0, 600px) 250px; - } -} -@media (min-width: 1300px) { - .app-shell__grid { - grid-template-columns: 240px minmax(0, 600px) 300px; - } -} .app-shell__center { min-height: 100vh; @@ -753,10 +757,27 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name { } /* Full-bleed shell: used by the profile page so its banner starts at - viewport top-left and extends edge-to-edge. The transparent navbar - floats over it (see .navbar--profile-overlay). - On desktop (≥800px) full-bleed scopes to the center cell, NOT the - viewport — the rails (PR2) sit outside this element. */ + viewport top-left and extends edge-to-edge. On mobile the transparent + navbar floats over the banner (see .navbar--profile-overlay). + On desktop (≥800px) full-bleed scopes to the center cell — the right + rail sits outside — and the opaque top bar sits above; the banner + inside the Overview tab starts immediately below the bar and scrolls + under it. */ + +/* Bump `--top-bar-total` whenever the desktop top bar renders its + second row (the profile/settings tabs row OR the cert/project + back-button row — both use `.desktop-top-bar__row--tabs`). The + default in tokens.css is row 1 only; this keeps anything sticking + off `--top-bar-total` (e.g. the inline-edit banner) positioned + below the actual top bar on every page that renders row 2. We + match on `html` because `.desktop-top-bar` is a sibling of + `.app-shell`, not a descendant. */ +@media (min-width: 800px) { + html:has(.desktop-top-bar__row--tabs) { + --top-bar-total: calc(var(--top-bar-row1) + var(--top-bar-row2)); + } +} + .app-shell--fullbleed .app-shell__content { max-width: none; padding-top: 0; @@ -764,6 +785,16 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name { padding-right: 0; } +/* Profile pages on desktop expand the content area to a GitHub-style + 1280px container. Non-Overview tabs inside the profile page constrain + themselves to a reading column via .profile-panel--reading. */ +@media (min-width: 800px) { + .app-shell--fullbleed .app-shell__content { + max-width: 1280px; + margin: 0 auto; + } +} + /* ========== Auth Guard Loading ========== */ .auth-guard-loading { display: flex; @@ -1347,8 +1378,8 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name { flex-direction: column; gap: 24px; position: sticky; - top: var(--navbar-height); - height: calc(100vh - var(--navbar-height)); + top: var(--top-bar-total); + height: calc(100vh - var(--top-bar-total)); padding: 16px 16px 24px; background: var(--bg-canvas); border-left: 1px solid var(--border-subtle); @@ -1703,3 +1734,1601 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name { border: 0; } +/* ========== Desktop Top Bar (positioning redesign) ========== + Two-row chrome rendered at ≥800px. Row 1 is brandmark + page title + (left) and search + Apps + Settings + switcher (right). Row 2 is + the profile tabs strip on /profile and /settings, or the back-row + on /activity and /project. Hidden on <800px — the mobile + handles that range. + + The bar scrolls with the page (no `position: sticky`) so the whole + layout — top bar + content — moves together as the user scrolls. */ + +.desktop-top-bar { + display: none; +} + +@media (min-width: 800px) { + .desktop-top-bar { + display: flex; + flex-direction: column; + background: var(--navbar-bg); + box-shadow: 0 1px 0 var(--navbar-border); + } + + /* Placeholder rendered by the Suspense fallback while the real + DesktopTopBar resolves on statically-prerendered pages. Reserves the + row-1 height so first paint doesn't jump when the real bar mounts. */ + .desktop-top-bar--placeholder { + height: var(--top-bar-row1); + } +} + +.desktop-top-bar__row { + display: flex; + align-items: center; + width: 100%; + max-width: 1536px; + margin: 0 auto; + padding: 0 32px; +} + +.desktop-top-bar__row--chrome { + height: var(--top-bar-row1); + justify-content: space-between; + gap: 24px; +} + +.desktop-top-bar__row--tabs { + height: var(--top-bar-row2); + gap: 20px; +} + +.desktop-top-bar__left { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.desktop-top-bar__brand { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + width: 36px; + color: var(--fg-primary); + text-decoration: none; + border-radius: var(--radius); + transition: opacity var(--transition-fast); + flex-shrink: 0; +} + +.desktop-top-bar__brand:hover { + opacity: 0.85; +} + +.desktop-top-bar__title { + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 1rem; + font-weight: 450; + color: var(--fg-primary); + letter-spacing: -0.01em; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.desktop-top-bar__title-part { + color: inherit; + text-decoration: none; + padding: 4px 8px; + margin: 0 -2px; + border-radius: 6px; + transition: background var(--transition-fast); +} + +.desktop-top-bar__title-part:hover { + background: var(--overlay-weak); +} + +.desktop-top-bar__title-part:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.desktop-top-bar__title-sep { + color: var(--fg-muted); + font-weight: 400; +} + +.desktop-top-bar__right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.desktop-top-bar__search { + width: 260px; +} + +@media (min-width: 1100px) { + .desktop-top-bar__search { + width: 300px; + } +} + +/* The PeopleSearch field has a 44px height by default; the top bar's + row 1 is 64px so we let the field breathe naturally. No override. */ + +.desktop-top-bar__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--fg-secondary); + background: transparent; + border-radius: var(--radius); + border: none; + text-decoration: none; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.desktop-top-bar__icon-btn:hover { + background: var(--overlay-weak); + color: var(--fg-primary); +} + +.desktop-top-bar__icon-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.desktop-top-bar__switcher-wrap { + display: inline-flex; + align-items: center; + margin-left: 4px; +} + +.desktop-top-bar__switcher { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + background: transparent; + border: none; + /* Use the design-system `--radius` token so the switcher's outer + corners match the surrounding chrome (icon buttons, the search bar, + etc.) instead of forming a pill. */ + border-radius: var(--radius); + cursor: pointer; + color: var(--fg-secondary); + transition: background var(--transition-fast); + max-width: 260px; +} + +.desktop-top-bar__switcher:hover { + background: var(--overlay-weak); +} + +.desktop-top-bar__switcher:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.desktop-top-bar__switcher-meta { + display: flex; + flex-direction: column; + line-height: 1.15; + min-width: 0; + text-align: left; +} + +.desktop-top-bar__switcher-name { + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.8125rem; + font-weight: 600; + color: var(--fg-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.desktop-top-bar__switcher-handle { + font-size: 0.6875rem; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Hide the textual meta on narrower viewports so the trigger stays + compact next to the search and icon-buttons. */ +@media (max-width: 1099.98px) { + .desktop-top-bar__switcher-meta { + display: none; + } +} + +.desktop-top-bar__signin-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 16px; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.8125rem; + font-weight: 500; + color: var(--fg-primary); + background: transparent; + border: 1px solid var(--border-hover); + border-radius: 999px; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); +} + +.desktop-top-bar__signin-btn:hover { + background: var(--fg-primary); + color: var(--bg-canvas); + border-color: var(--fg-primary); +} + +.desktop-top-bar__signin-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* Profile tabs strip (row 2). Underline-active style mirrors the + in-page tabs used on mobile (.profile-tabs__tab) — keep them visually + identical so switching between viewports doesn't surprise users. */ +.desktop-top-bar__tabs { + display: flex; + align-items: stretch; + gap: 0; + height: 100%; +} + +/* Back affordance rendered in the row-2 slot on cert / project detail + pages. Left-aligned, sits at the same height as the tab strip. */ +.desktop-top-bar__back { + display: inline-flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 4px; + background: transparent; + border: none; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + transition: color var(--transition-fast); +} + +.desktop-top-bar__back:hover { + color: var(--fg-primary); +} + +.desktop-top-bar__back:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.desktop-top-bar__tab { + position: relative; + display: inline-flex; + align-items: center; + padding: 0 18px; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + text-decoration: none; + white-space: nowrap; + transition: color var(--transition-fast); +} + +.desktop-top-bar__tab:hover { + color: var(--fg-primary); +} + +.desktop-top-bar__tab--active { + color: var(--fg-primary); + font-weight: 600; +} + +.desktop-top-bar__tab--active::after { + content: ""; + position: absolute; + left: 18px; + right: 18px; + bottom: 0; + height: 2px; + background: var(--fg-primary); + border-radius: 1px; +} + +.desktop-top-bar__tab:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; + border-radius: 2px; +} + +/* Top-bar variant of the account switcher menu — sits below the trigger + instead of above it. Position is supplied inline. */ +.account-switcher__menu--top-bar { + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 8px; + z-index: var(--z-popover); +} + +/* ========== Profile page CSS scaffolding for the top-bar redesign ========== + On desktop the mobile-only ProfileHeader is hidden; the Overview tab's + left sidebar carries the identity instead, and the top bar's row 2 + tabs are the single source of profile tab navigation (the in-page + tab strip was removed in the Overview rewrite). */ + +@media (min-width: 800px) { + .profile-page__mobile-header { + display: none; + } +} + +/* ========== Profile page layout (GitHub-style 2-column) ========== + The page renders a left identity sidebar and a right main pane that + together carry the active tab content. The sidebar is constant; the + right pane swaps between Overview / Certs / Projects / Endorsements. + + On mobile (<800px) the columns collapse to a single stack and the + sidebar is hidden — the page's above already carries + identity on small viewports. */ + +/* Shared 2-column page layout used by both the profile page and the + cert detail page (and any future entity detail page). The class was + originally `.profile-page__layout` — renamed to `.page-layout` once + the cert page started reusing the same grid so the naming reflects + the broader scope. */ +.page-layout { + display: grid; + gap: 24px; + padding: 24px 16px; +} + +@media (min-width: 800px) { + .page-layout { + grid-template-columns: 296px minmax(0, 1fr); + gap: 32px; + align-items: start; + padding: 24px; + } +} + +.page-layout__main { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +/* ---------- Identity sidebar ---------- */ + +.profile-sidebar { + display: none; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +@media (min-width: 800px) { + .profile-sidebar { + display: flex; + } +} + +.profile-sidebar__avatar { + /* The inner is sized via Tailwind override (!h-[240px] + !w-[240px]); this wrapper just provides the layout slot. */ + display: inline-flex; +} + +.profile-sidebar__name-block { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; +} + +.profile-sidebar__name { + font-family: var(--font-headline), 'Noto Serif', serif; + font-size: 1.625rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--fg-primary); + margin: 0; + line-height: 1.2; +} + +.profile-sidebar__handle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 1.125rem; + color: var(--fg-muted); + margin: 0; + line-height: 1.2; + word-break: break-word; +} + +.profile-sidebar__did { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 4px 0 0; + font-size: 0.6875rem; + color: var(--fg-muted); +} + +.profile-sidebar__did-value { + font-family: monospace; + font-size: 0.6875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + user-select: all; +} + +.profile-sidebar__copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + margin-left: 2px; + color: var(--fg-muted); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.profile-sidebar__copy-btn:hover { + background: var(--overlay-weak); + color: var(--fg-primary); +} + +.profile-sidebar__copy-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.profile-sidebar__pronouns { + font-size: 0.8125rem; + color: var(--fg-muted); + margin: 0; +} + +.profile-sidebar__actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + /* Reserve the action row's height even when empty — keeps the layout + anchored while the user is in edit mode (the "Edit profile" button + is hidden but its slot remains as whitespace). */ + min-height: 36px; +} + +.profile-sidebar__action-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1; + height: 36px; + padding: 0 12px; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: var(--fg-primary); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius); + text-decoration: none; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.profile-sidebar__action-primary:hover { + background: var(--bg-sunken); + border-color: var(--border-hover); +} + +.profile-sidebar__action-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + color: var(--fg-secondary); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius); + text-decoration: none; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.profile-sidebar__action-secondary:hover { + background: var(--bg-sunken); + color: var(--fg-primary); +} + +.profile-sidebar__followers { + display: flex; + align-items: center; + gap: 6px; + /* Pull a bit of extra space above the followers row so it reads as a + visually-separate block from the action row, and below it to push + the links/joined list further down. */ + margin: 8px 0 12px; + font-size: 0.875rem; + color: var(--fg-secondary); + flex-wrap: wrap; +} + +.profile-sidebar__followers-count { + font-weight: 600; + color: var(--fg-primary); +} + +/* Each count is its own deep-link into the Followers tab's matching + sub-tab. The link itself reads like body text — the count number + inside picks up the bold treatment via `.profile-sidebar__followers- + count`. Hovering nudges the label/count toward `--fg-primary` so + the affordance is discoverable without underlining everything. */ +.profile-sidebar__followers-link { + color: inherit; + text-decoration: none; + transition: color var(--transition-fast); +} + +.profile-sidebar__followers-link:hover, +.profile-sidebar__followers-link:focus-visible { + color: var(--fg-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.profile-sidebar__followers-sep { + color: var(--fg-muted); +} + +.profile-sidebar__details { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.profile-sidebar__details li { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.875rem; + color: var(--fg-secondary); + min-width: 0; +} + +/* The "Joined" / "Founded" row sits below the URL block; push it + slightly farther so URLs vs. date read as two separate blocks. We + target the date row by class — not `:last-child` — so the visual + break never accidentally lands between two adjacent URL rows + (which used to happen when the date row was hidden). */ +.profile-sidebar__details-date { + margin-top: 10px; +} + +.profile-sidebar__details li > svg { + flex-shrink: 0; + color: var(--fg-muted); +} + +.profile-sidebar__detail-link { + color: var(--fg-secondary); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + transition: color var(--transition-fast); +} + +.profile-sidebar__detail-link:hover { + color: var(--fg-primary); + text-decoration: underline; +} + +/* Bluesky-import marker. Shown on profiles whose data was imported + from a bluesky-hosted PDS (no Certified-side profile record). The + row drops the leaf-icon left affordance so the chip reads as a + metadata tag rather than another entry in the link list. */ +.profile-sidebar__bsky-row { + /* The shared `.profile-sidebar__details li` rule pads-left for the + left-edge icon column; we don't render an icon here, so reset. */ + padding-left: 0 !important; +} + +.profile-sidebar__bsky-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + background: var(--overlay-weak); + border: 1px solid var(--border-default); + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 500; + color: var(--fg-secondary); + text-decoration: none; + letter-spacing: 0.02em; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.profile-sidebar__bsky-tag:hover, +.profile-sidebar__bsky-tag:focus-visible { + background: var(--overlay-medium); + color: var(--fg-primary); + /* No underline — chip semantics, not a body link. The browser- + native title tooltip carries the explanatory text. */ + text-decoration: none; +} + +.profile-sidebar__section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.profile-sidebar__section-title { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + margin: 0; +} + +.profile-sidebar__section-title--link { + text-decoration: none; + transition: color var(--transition-fast); +} + +.profile-sidebar__section-title--link:hover { + color: var(--fg-primary); +} + +.profile-sidebar__see-all { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-secondary); + text-decoration: none; + transition: color var(--transition-fast); +} + +.profile-sidebar__see-all:hover { + color: var(--fg-primary); +} + +.profile-sidebar__loading { + padding: 12px 0; +} + +.profile-sidebar__empty { + font-size: 0.875rem; + color: var(--fg-muted); + margin: 0; + padding: 8px 0; +} + +/* Groups grid (GitHub orgs style) */ + +.profile-sidebar__groups { + margin-top: 12px; + padding-top: 16px; + border-top: 1px solid var(--border-subtle); +} + +.profile-sidebar__groups-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.profile-sidebar__group-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + margin: 0 -8px; + border-radius: 6px; + text-decoration: none; + color: inherit; + transition: background var(--transition-fast); +} + +.profile-sidebar__group-row:hover { + background: var(--overlay-weak); +} + +.profile-sidebar__group-meta { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.2; +} + +.profile-sidebar__group-name { + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.8125rem; + font-weight: 600; + color: var(--fg-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-sidebar__group-handle { + font-size: 0.6875rem; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Right pane: Overview tab content ---------- */ + +.profile-overview { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +.profile-overview__banner { + display: none; +} + +@media (min-width: 800px) { + .profile-overview__banner { + display: block; + width: 100%; + aspect-ratio: 3 / 1; + max-height: 220px; + overflow: hidden; + border-radius: var(--radius); + background: linear-gradient( + 135deg, + var(--bg-sunken) 0%, + var(--bg-raised) 100% + ); + } + .profile-overview__banner--empty { + aspect-ratio: 6 / 1; + max-height: 96px; + } +} + +.profile-overview__banner-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.profile-overview__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.profile-overview__stat { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius); + text-decoration: none; + color: inherit; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.profile-overview__stat:hover { + border-color: var(--border-hover-soft); + background: var(--bg-sunken); +} + +.profile-overview__stat-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); +} + +.profile-overview__stat-split { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.profile-overview__stat-split--solo { + justify-content: flex-start; +} + +.profile-overview__stat-value { + font-family: var(--font-headline), 'Noto Serif', serif; + font-size: 1.5rem; + font-weight: 700; + color: var(--fg-primary); + letter-spacing: -0.01em; + line-height: 1.1; +} + +.profile-overview__stat-sub { + font-size: 0.75rem; + color: var(--fg-muted); + letter-spacing: 0.01em; +} + +/* ---------- About section ---------- */ + +.profile-overview__about { + display: flex; + flex-direction: column; + gap: 8px; +} + +.profile-overview__about-body { + font-size: 0.9375rem; + line-height: 1.55; + color: var(--fg-secondary); + margin: 0; + white-space: pre-wrap; +} + +/* About + side map. Falls back to a single column when no map is set + or the viewport is too narrow for the right column to read well. */ +.profile-overview__about-block { + display: block; +} + +.profile-overview__about-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (min-width: 900px) { + .profile-overview__about-block--with-map { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 24px; + align-items: start; + } +} + +/* Organization type tags (read mode) */ +.profile-overview__types { + display: flex; + flex-direction: column; + gap: 10px; +} + +.profile-overview__type-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + list-style: none; + padding: 0; + margin: 0; +} + +.profile-overview__type-tag { + display: inline-flex; + align-items: center; + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-secondary); + padding: 3px 10px; + border-radius: 999px; + background: var(--overlay-weak); + border: 1px solid var(--border-subtle); + line-height: 1.4; +} + +/* Location column (read + edit). The wrapper handles spacing between + the map, the label, and the picker controls. */ +.profile-overview__location { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.profile-overview__location-map { + width: 100%; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-subtle); +} + +.profile-overview__location-name { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + color: var(--fg-secondary); + margin: 0; +} + +.profile-overview__location-name svg { + flex-shrink: 0; + color: var(--fg-muted); +} + +.profile-overview__location-picker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.profile-overview__location-hint { + font-size: 0.75rem; + color: var(--fg-muted); + margin: 0; +} + +.profile-overview__location-clear { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--fg-secondary); + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.profile-overview__location-clear:hover { + background: var(--overlay-weak); + color: var(--fg-primary); +} + +.profile-overview__location-attribution { + font-size: 0.6875rem; + color: var(--fg-muted); + margin: 0; +} + +.profile-overview__location-attribution a { + color: inherit; + text-decoration: underline; +} + +/* Autocomplete combobox: input + absolutely-positioned suggestions + panel beneath. The wrapper is `position: relative` so the panel + anchors to the input rather than to the page. */ +.profile-overview__location-combobox { + position: relative; +} + +.profile-overview__location-suggestions { + list-style: none; + margin: 4px 0 0; + padding: 4px; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 5; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 8px; + box-shadow: var(--shadow-md); + max-height: 320px; + overflow-y: auto; +} + +.profile-overview__location-suggestion { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: background var(--transition-fast); +} + +.profile-overview__location-suggestion:hover, +.profile-overview__location-suggestion--active { + background: var(--overlay-weak); +} + +.profile-overview__location-suggestion-primary { + font-size: 0.875rem; + color: var(--fg-primary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-overview__location-suggestion-secondary { + font-size: 0.75rem; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Section heads + digest lists ---------- */ + +.profile-overview__section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.profile-overview__section-title { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + margin: 0; +} + +.profile-overview__see-all { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-secondary); + text-decoration: none; + transition: color var(--transition-fast); +} + +.profile-overview__see-all:hover { + color: var(--fg-primary); +} + +.profile-overview__loading { + padding: 12px 0; +} + +/* ---------- Profile certs tab ---------- */ + +.profile-certs { + display: flex; + flex-direction: column; +} + +.profile-certs__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.profile-certs__subtabs { + display: flex; + gap: 0; +} + +.profile-certs__subtab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 4px; + margin-right: 20px; + background: transparent; + border: none; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + position: relative; + transition: color var(--transition-fast); + white-space: nowrap; +} + +.profile-certs__subtab:hover { + color: var(--fg-primary); +} + +.profile-certs__subtab--active { + color: var(--fg-primary); + font-weight: 600; +} + +.profile-certs__subtab--active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + background: var(--fg-primary); + border-radius: 1px; +} + +.profile-certs__subtab:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 4px; + border-radius: 2px; +} + +.profile-certs__subtab-count { + font-size: 0.75rem; + font-weight: 500; + color: var(--fg-muted); + padding: 1px 7px; + border-radius: 999px; + background: var(--overlay-weak); +} + +.profile-certs__subtab--active .profile-certs__subtab-count { + color: var(--fg-primary); +} + +.profile-certs__controls { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding-bottom: 8px; +} + +.profile-certs__search { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 12px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 8px; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.profile-certs__search:focus-within { + border-color: var(--fg-primary); +} + +.profile-certs__search-icon { + color: var(--fg-muted); + flex-shrink: 0; +} + +.profile-certs__search-input { + background: transparent; + border: none; + outline: none; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.875rem; + color: var(--fg-primary); + width: 200px; +} + +.profile-certs__search-input::placeholder { + color: var(--fg-muted); +} + +/* Strip the WebKit/Blink default 'x' button on type=search so it + doesn't fight with the icon styling. */ +.profile-certs__search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.profile-certs__sort-wrap { + position: relative; + display: inline-flex; +} + +.profile-certs__sort-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 8px; + color: var(--fg-secondary); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.profile-certs__sort-btn:hover { + background: var(--overlay-weak); + color: var(--fg-primary); +} + +.profile-certs__sort-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.profile-certs__sort-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 180px; + padding: 4px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 8px; + box-shadow: var(--shadow-md); + z-index: 30; +} + +.profile-certs__sort-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: none; + border-radius: 6px; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 0.875rem; + color: var(--fg-primary); + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); +} + +.profile-certs__sort-item:hover { + background: var(--overlay-weak); +} + +.profile-certs__sort-item-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: var(--fg-primary); + flex-shrink: 0; +} + +/* On wider viewports, lay cert cards out as a grid. Drop the default + per-card bottom divider (the shared .feed style) since grid gap now + provides separation. Cards are uniform size: square image + 2-line + title + 2-line description + meta row pinned to the bottom. */ +@media (min-width: 800px) { + .profile-certs .feed { + display: grid; + /* Fixed 3-track grid: each track is at most 1/3 of the container so + a single cert renders at the same width as one of three in a row. + `minmax(0, …)` lets the track shrink instead of pushing past 1/3 + when its content is wide. */ + grid-template-columns: repeat(3, minmax(0, calc((100% - 48px) / 3))); + gap: 28px 24px; + } + + .profile-certs .feed-card { + padding: 10px; + border-bottom: none; + border-radius: 4px; + display: flex; + flex-direction: column; + transition: background var(--transition-fast); + } + + .profile-certs .feed-card:hover { + background: var(--overlay-weak); + } + + /* Cards in the certs grid get a card-wide hover background — drop + the title underline that's used in the stacked feed views. */ + .profile-certs .feed-card__body:hover .feed-card__title { + text-decoration: none; + } + + .profile-certs .feed-card__body { + display: flex; + flex-direction: column; + flex: 1; + } + + /* Title: 2-line clamp with reserved height so short titles still + leave the description and meta row at the same vertical position + across cards. */ + .profile-certs .feed-card__title { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: calc(1.125rem * 1.3 * 2); + } + + /* Description: 2-line clamp with reserved height. */ + .profile-certs .feed-card__desc { + -webkit-line-clamp: 2; + min-height: calc(0.875rem * 1.6 * 2); + } + + /* Meta row pinned to the bottom regardless of title/desc length. */ + .profile-certs .feed-card__meta { + margin-top: auto; + } + + .profile-certs .feed__sentinel { + grid-column: 1 / -1; + } + + /* Empty / error states inside the grid: span every column so the + EmptyState's own centering renders across the full pane instead + of sitting in the first grid cell. */ + .profile-certs .feed > .empty-state { + grid-column: 1 / -1; + } +} + +/* 3-track grid applies from 800px upwards — no breakpoint change needed + at 1100px; the same `minmax(0, 1/3)` rule keeps cards capped to 1/3 of + the container at every width. */ + +.profile-overview__empty { + font-size: 0.875rem; + color: var(--fg-muted); + margin: 0; + padding: 8px 0; +} + +.profile-overview__digest { + display: flex; + flex-direction: column; +} + +.profile-overview__activity-list, +.profile-overview__endorse-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.profile-overview__activity-item, +.profile-overview__endorse-item { + border-top: 1px solid var(--border-subtle); +} + +.profile-overview__activity-item:last-child, +.profile-overview__endorse-item:last-child { + border-bottom: 1px solid var(--border-subtle); +} + +.profile-overview__activity-link { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + text-decoration: none; + color: inherit; +} + +.profile-overview__activity-link:hover .profile-overview__activity-title { + text-decoration: underline; +} + +.profile-overview__activity-thumb { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + overflow: hidden; + border-radius: var(--radius); + background: var(--bg-sunken); +} + +.profile-overview__activity-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.profile-overview__activity-thumb--placeholder { + background: + linear-gradient( + 135deg, + var(--bg-sunken) 0%, + var(--overlay-weak) 100% + ); + color: var(--fg-muted); +} + +.profile-overview__activity-thumb--placeholder svg { + opacity: 0.6; +} + +.profile-overview__activity-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.profile-overview__activity-title { + font-size: 0.9375rem; + font-weight: 500; + color: var(--fg-primary); +} + +.profile-overview__activity-desc { + font-size: 0.8125rem; + color: var(--fg-secondary); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.profile-overview__activity-meta { + font-size: 0.6875rem; + color: var(--fg-muted); + margin-top: 4px; +} + +.profile-overview__endorse-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px 0; + color: inherit; + text-decoration: none; +} + +.profile-overview__endorse-link:hover .profile-overview__endorse-name { + text-decoration: underline; +} + +.profile-overview__endorse-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.profile-overview__endorse-name { + font-size: 0.9375rem; + font-weight: 500; + color: var(--fg-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-overview__endorse-when { + font-size: 0.6875rem; + color: var(--fg-muted); +} + +.profile-overview__endorse-note { + font-size: 0.8125rem; + color: var(--fg-muted); + font-style: italic; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +/* ============================================================ + Site footer — minimal, GitHub-style. + + One horizontal row at desktop (small brandmark + copyright on the + left, link list on the right); wraps to two stacked rows on + narrow viewports. Sits inside `.app-shell__center` so the + gutters / max-width come from the existing grid. Top border + keeps the line between content and footer subtle. + ============================================================ */ + +.site-footer { + border-top: 1px solid var(--border-subtle); + padding: 16px 0 24px; + margin-top: 48px; + font-size: 0.75rem; + color: var(--fg-muted); +} + +.site-footer__row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +.site-footer__left { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.site-footer__brand { + color: var(--fg-muted); + opacity: 0.7; +} + +.site-footer__nav { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 16px; +} + +.site-footer__link { + color: var(--fg-muted); + text-decoration: none; + transition: color var(--transition-fast); +} + +.site-footer__link:hover { + color: var(--fg-primary); +} + +.site-footer__link:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 2px; +} + diff --git a/src/app/styles/leaflet.css b/src/app/styles/leaflet.css new file mode 100644 index 00000000..41b73634 --- /dev/null +++ b/src/app/styles/leaflet.css @@ -0,0 +1,513 @@ +/* ============================================================ + Leaflet document — neutral renderer + TipTap editor styles. + + `leaflet-doc__*` styles the read-only walk of a + `pub.leaflet.pages.linearDocument` value. `leaflet-editor__*` + styles the TipTap editor wrapper, toolbar, and surface. + + Both class namespaces are surface-agnostic by design — the cert + detail page, the org profile, and any future caller layer their + own positioning / max-width via the wrapper. + ============================================================ */ + +/* ---------- Renderer ---------- */ + +.leaflet-doc { + display: flex; + flex-direction: column; + gap: 12px; + color: var(--fg-primary); +} + +.leaflet-doc__para { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--fg-secondary); + margin: 0; + white-space: pre-wrap; +} + +.leaflet-doc__heading { + font-family: var(--font-headline), 'Noto Serif', serif; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.01em; + color: var(--fg-primary); + margin: 8px 0 0; +} + +.leaflet-doc__heading:first-child { + margin-top: 0; +} + +h2.leaflet-doc__heading { font-size: 1.25rem; } +h3.leaflet-doc__heading { font-size: 1.125rem; } +h4.leaflet-doc__heading { font-size: 1rem; } +h5.leaflet-doc__heading, +h6.leaflet-doc__heading { font-size: 0.9375rem; } + +/* Tailwind's preflight resets `list-style: none` and zeroes padding + on every `