diff --git a/CLAUDE.md b/CLAUDE.md
index 31d0ee0..f1c7110 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,8 @@ A `.env` file is required with the following variables:
- `VITE_CANONICAL_URL` - Canonical URL for the application
- `PODPING_ENDPOINT_URL` - Full URL to MSP's self-hosted podping-hivepinger Railway service, trailing slash (optional; podping notifications are skipped when unset)
- `PODPING_BEARER_TOKEN` - Bearer token shared with the Railway service (optional; podping notifications are skipped when unset)
+- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` - Google OAuth client credentials for managed-keypair sign-in (optional; `/api/auth/google-start` returns 503 "not configured" when unset)
+- `AUTH_SECRET` - HKDF root secret used to encrypt every stored managed nsec and to sign session JWTs (`api/_utils/authUtils.ts`). **Load-bearing & must stay fixed** — rotating it orphans all stored managed keypairs (they become undecryptable)
No `.env.example` exists - request credentials from the team.
@@ -32,12 +34,23 @@ npm run dev
## Deployment
-- Hosted on Vercel at msp.podtards.com
+- Hosted on Vercel. Canonical domain is **musicsideproject.com**; `msp.podtards.com` is a legacy alias of the same project (still resolves, but must not appear in newly generated URLs)
- API functions in `/api/` directory are Vercel serverless functions
-- Dev server proxies `/api/*` to production
+- Dev server proxies `/api/*` to production (`musicsideproject.com`)
- Build: `npm run build` (tsc + vite)
- Build auto-unshallows Vercel's git clone for accurate version computation
+### Typechecking — use `npm run build`, not `tsc --noEmit`
+The root `tsconfig.json` is **references-only** (`files: []` + references to `tsconfig.app.json` / `tsconfig.node.json`), so `tsc --noEmit` against it checks **zero files** and always passes — a false green. Always verify types with `npm run build` (which runs `tsc -b && vite build`) or at minimum `npx tsc -b`. (Lint is separate: `npm run lint`.)
+
+### Canonical feed domain
+All hosted feed URLs (album/video/publisher) use the canonical domain, never the request host:
+- `getBaseUrl()` (`api/_utils/feedUtils.ts`) returns `process.env.CANONICAL_URL` (default `https://musicsideproject.com`) — it ignores the request host so a feed created on any alias/preview deploy still reports the canonical URL.
+- `buildHostedUrl()` (`src/utils/hostedFeed.ts`) uses `VITE_CANONICAL_URL` (default `https://musicsideproject.com`).
+- MSP-hosted detection (`isMspUrl` in `xmlParser.ts`, `isMspHosted` in `publisherPublish.ts`) and the `/api/proxy-feed` allowlist match both `musicsideproject.com` and the legacy `msp.podtards.com`.
+- The `/api/hosted/[feedId].xml` GET serves `Content-Type: application/xml` so browsers render feeds inline in a tab instead of downloading (podcast apps parse either XML content-type identically).
+- Changing the canonical only affects **newly generated/re-saved** URLs; feeds already registered in Podcast Index keep their original URL until re-saved.
+
### Versioning
Version is auto-computed at build time from git commit count: `0.1.{count - 255}` (zero-padded). Each push to master increments the patch number. Configured in `vite.config.ts` via `getAutoVersion()`, with `package.json` version as fallback when git is unavailable. Displayed in the hamburger menu.
@@ -112,7 +125,7 @@ gh issue view # View issue details
## Commands
```bash
-npm run dev # Start Vite dev server (proxies /api to msp.podtards.com)
+npm run dev # Start Vite dev server (proxies /api to musicsideproject.com)
npm run build # TypeScript compile + Vite build
npm run lint # ESLint
npm run test # Run tests with Vitest
@@ -122,11 +135,12 @@ npm run preview # Preview production build
## Architecture
-### Three Feed Modes
-The app has three modes selected via dropdown in the header:
+### Four Feed Modes
+The app has four modes selected via dropdown in the header:
- **Album** - Music album RSS feeds with tracks
- **Video** - Video feed RSS (similar structure to Album)
- **Publisher** - Label/publisher catalog feeds that aggregate multiple album feeds
+- **Artist (Album + Publisher)** - Combined editor that renders the Album editor stacked above the Publisher editor with cross-linked GUIDs. First-time-artist onboarding path so users don't context-switch between two modes to set up a single release + label catalog. Selecting it from the dropdown auto-creates whichever side is missing in `handleSwitchFeedType('artist')` (App.tsx) and routes to `` (`src/components/Editor/ArtistEditor.tsx`), which composes `` + `` (both gained a `chromeless?: boolean` prop so they can render without their own `main-content` / `editor-panel` wrapper). The bottom of the combined view renders `` — an inline guided-publish panel; see "Artist mode" subsection below.
### State Management
Uses React Context + useReducer pattern (not Redux). Three separate stores:
@@ -137,7 +151,7 @@ Uses React Context + useReducer pattern (not Redux). Three separate stores:
Actions are dispatched via reducer pattern. The `FeedAction` union type in `feedStore.tsx` defines all available actions.
### Core Data Types (src/types/feed.ts)
-- `FeedType` - `'album' | 'video' | 'publisher'` (canonical definition, re-exported from `feedStore.tsx`)
+- `FeedType` - `'album' | 'video' | 'publisher' | 'artist'` (canonical definition, re-exported from `feedStore.tsx`; `'artist'` is the combined-editor UI mode — see "Four Feed Modes")
- `Album` - Feed metadata + array of `Track`s
- `Track` - Individual items with optional per-track value recipients
- `Person` - Contributors with roles (uses Podcasting 2.0 taxonomy)
@@ -150,11 +164,14 @@ Vercel serverless functions:
- `pisearch.ts` - Podcast Index search
- `pisubmit.ts` - Submit feed to Podcast Index
- `pubnotify.ts` - Podcast Index pub notification + feed lookup; accepts optional `medium` query param and fire-and-forgets a podping via `notifyPodping()` in parallel so the toolbar "Podcast Index" button hits both indexing pathways
+- `pi-resolve.ts` - **read-only** GET `?guid=` → resolves a feed's numeric Podcast Index page (`https://podcastindex.org/podcast/`) via `lookupPodcastIndexId()` (byguid). NO side effects (no pubnotify ping / podping / `add/byfeedurl`), unlike `pubnotify.ts` — so it's **safe to poll** while PI finishes indexing a freshly published feed. The wizard's post-publish screen polls it (see below)
- `podping.ts` - Broadcast feed update via self-hosted hivepinger Railway service (requires `PODPING_ENDPOINT_URL` + `PODPING_BEARER_TOKEN`); rate-limited 10/hour per IP
- `proxy-feed.ts` - CORS proxy for fetching external feeds
- `hosted/` - MSP feed hosting endpoints (create, update, delete, backup/restore)
- `feed/[npub]/[guid].ts` - Nostr-stored feed retrieval
- `admin/` - Admin authentication (challenge/verify)
+- `auth/` - **Google OAuth → managed Nostr keypair sign-in** (currently on `new-onboarding-v2` only, not yet on master). `google-start.ts` redirects to Google's consent screen (builds `redirect_uri` from the request `Host` header — so every host that serves this needs its own Authorized Redirect URI registered in the Google OAuth client, or Google rejects with `redirect_uri_mismatch`); `google-callback.ts` exchanges the code, creates-or-loads the user's encrypted keypair blob, and sets the session cookie; `keypair.ts` returns the decrypted secret key (hex) for the logged-in user so the client can init a signer; `me.ts` returns the session user (pubkey/npub/email/displayName/picture) from the JWT; `logout.ts` clears the session cookie
+- `_utils/authUtils.ts` - Managed-keypair crypto + session helpers: HKDF-derives an AES-GCM key from `AUTH_SECRET`+userId to `encryptNsec`/`decryptNsec`, `signJwt`/`verifyJwt` (30-day sessions), session-cookie helpers, and blob path/prefix helpers (`sha256(googleId)`, stored via `put(addRandomSuffix: true)` so the object name isn't guessable). Encrypted keypairs live in Vercel Blob (`BLOB_READ_WRITE_TOKEN`)
- `_utils/podcastIndex.ts` - Shared Podcast Index auth headers
- `_utils/feedUtils.ts` - Shared feed utilities (PI notification, podping notification, `isPodpingConfigured()` helper, UUID validation, token hashing)
- `_utils/rateLimiter.ts` - In-memory fixed-window IP rate limiter used by `/api/podping`
@@ -171,6 +188,7 @@ Vercel serverless functions:
- **Backup retention**: `backupFeed()` helper in `api/hosted/[feedId].ts` creates timestamped backups before PUT, DELETE, and restore operations; keeps only the 10 most recent backups per feed
- **Podping**: `notifyPodcastIndex()` fire-and-forgets `notifyPodping()` after the PI pubnotify ping. Sends `GET ${PODPING_ENDPOINT_URL}?url=...` with `Authorization: Bearer ${PODPING_BEARER_TOKEN}`. The endpoint is MSP's self-hosted [podping-hivepinger](https://github.com/brianoflondon/podping-hivepinger) deployment on Railway (repo: `ChadFarrow/msp-podping-service`), fronted by a Caddy sidecar enforcing the bearer token. Silently no-ops when either env var is unset (`isPodpingConfigured()` in `api/_utils/feedUtils.ts` is the canonical gate). The fire-and-forget call site uses a `.then()` that `console.warn`s on failure so Vercel function logs surface hivepinger outages. `/api/podping` exposes a manual endpoint behind a 10/hour per-IP rate limit. `/api/pubnotify` also fires a podping (same fire-and-forget pattern) so the "Podcast Index" toolbar button hits both indexing pathways. UI entry point for a pure podping (no PI call): the standalone **Podping** button on the bottom toolbar (`PodpingModal.tsx` — opens a mini modal with just a URL field + submit, `reason` is hardcoded to `'update'`). The SaveModal previously had a "Send Podping" destination; it was removed in favor of the dedicated toolbar button.
- **Podping `medium` — load-bearing**: hivepinger uses the `medium` value to build the custom_json op id as `pp__` (e.g. `pp_music_update`). The companion consumer in `msp-podping-service` filters `pp_music_*` only, so any code path that fires a podping WITHOUT a medium ends up as `pp_podcast_update` (hivepinger's default) and is invisible to the consumer. Every client path that can trigger a podping passes medium: hosted POST/PUT (extracted via `extractPodcastMedium()` from the XML), SaveModal's nsite follow-up and "Submit to PodcastIndex" destination (`album.medium` / `publisherFeed.medium`), `publisherPublish.ts`'s internal `notifyPodcastIndex()` helper (takes a `medium` param forwarded to `/api/pubnotify`), PublisherFeedReminderSection (`publisherFeed.medium`). The PodpingModal toolbar button reads medium from the feed (`album.medium` / `videoFeed.medium` / `publisherFeed.medium`), matching the SaveModal pattern. Publisher feeds carry `medium: 'publisher'` which produces `pp_publisher_update` — still filtered out by the music-only consumer, preserving the prior intent without special-casing. When adding a new podping trigger, always plumb through the feed's medium — the `isPodpingConfigured()` gate + `notifyPodping(url, { medium })` signature is the canonical call site pattern.
+- **PI publisher-feed indexing — partially understood, parked**: empirically, `add/byfeedurl` works reliably for `medium=music` feeds (the album shows up in PI within ~5 min) but does NOT reliably auto-index `medium=publisher` feeds when submitted via our `/api/hosted` flow. PI returns an empty body for the publisher submission (just like it does for music; empty body isn't a rejection signal), but the publisher either never gets parsed or gets registered with empty metadata (`title=""`, an auto-generated UUID-v5 `podcastGuid` derived from the URL, no image). Manual submission via podcastindex.org's web form succeeds for the same XML, so PI's web form and API endpoints aren't the same code path on PI's side. Fixes attempted (`8557c7d` cross-link `feedUrl` injection, `99c89eb` empty-recipient filter) helped but weren't sufficient on test feeds. Candidate causes still to rule out: heuristic spam filter on "test"-heavy content, PI API key write-permission level, single thin remoteItem catalog vs. Longy-style 14-item catalogs, Vercel cold-start during PI's first crawl. The verify step's polling cycle includes a "check manually →" fallback link to `podcastindex.org/search?q=` when a feed doesn't land within the budget. See PR #63 conversation for the full diagnostic trail.
### Save Modal Destinations
The Save modal (`src/components/modals/SaveModal.tsx`) offers nine destinations. Each is a different combination of *where the bytes live* and *who can consume them* — important context when deciding which one to point a user at:
@@ -180,8 +198,10 @@ The Save modal (`src/components/modals/SaveModal.tsx`) offers nine destinations.
| Local Storage | Album/Video/Publisher state | Browser localStorage | No |
| Download XML | Generated RSS XML | User's filesystem | No |
| Copy to Clipboard | Generated RSS XML | Clipboard | No |
-| Host on MSP | Generated RSS XML | Vercel Blob (`feeds/{feedId}.xml`) | Yes — `https://msp.podtards.com/api/hosted/{feedId}` |
+| Host on MSP | Generated RSS XML | Vercel Blob (`feeds/{feedId}.xml`) | Yes — `https://musicsideproject.com/api/hosted/{feedId}.xml` |
+| Host on MSP (album + publisher) | Both XMLs in sequence; Artist mode only when `isLoggedIn` | Vercel Blob (one entry per feed) | Yes — both feeds hosted with cross-link `feedUrl`s injected; see `hostBothOnMSP` in `src/utils/artistPublish.ts`. The dropdown swaps the single "Host on MSP" option for this combined variant when `feedType === 'artist'`. |
| Submit to PodcastIndex | Feed URL (not the bytes) submitted to PI via `/api/pubnotify` | — (registration only) | Indirectly — PI indexes the URL so apps like Fountain/Castamatic can discover it |
+| Download Feed Package (album + publisher) | Both XMLs + `next-steps.txt` | User's filesystem | No (export only); paired with Artist mode for users who self-host |
| Save RSS feed to Nostr | Full RSS XML embedded in a kind 30054 event | Nostr relays only | No — only MSP reads kind 30054 (cross-device sync) |
| Publish to Nostr Music | Per-track events (kind 36787) + playlist event (kind 34139) | Nostr relays | No — Nostr-native music clients only (Wavlake, Fountain, etc.). Audio files must already be hosted elsewhere; the events just reference enclosure URLs |
| Publish RSS feed to a Blossom server | Generated RSS XML | Blossom server (content-addressed) + kind 1063 NIP-94 pointer event on Nostr | Yes — `${origin}/api/feed/{npub}/{podcastGuid}.xml` resolves the pointer and 302s to the latest Blossom URL |
@@ -191,9 +211,55 @@ Login-gated options (everything from "Save RSS feed to Nostr" down) are conditio
Most experimental/power-user options are additionally gated behind a "Show Experimental Features" toggle in the hamburger menu (`src/store/experimentalStore.tsx`, localStorage key `msp-show-experimental`, default off). With the toggle off, the Save modal dropdown collapses to the production-ready set: Local Storage, Download XML, Copy to Clipboard, Host on MSP, Submit to PodcastIndex, and (when logged in) Publish to Nostr Music. The Import modal applies the same gate to "Nostr Event" and "From Nostr." When adding a new experimental destination/import source: gate it with `showExperimental` from `useExperimental()`, suffix the visible label with a trailing ` 🧪` marker, sort it to the bottom of its dropdown and help-list (after all non-experimental options), and add a mode-reset `useEffect` so the dropdown snaps back to a safe default if the user flips the toggle off mid-flow. The experimental store follows the same Provider+`useX()`-in-one-file pattern as the other stores (`themeStore`, `feedStore`, `nostrStore`); `eslint.config.js` carves out `react-refresh/only-export-components` for `src/store/*.{ts,tsx}` since these are plumbing files, not fast-refresh-sensitive UI.
+### Artist mode (combined editor)
+First-time-artist onboarding flow that creates both an album feed and a publisher catalog with cross-linked GUIDs in a single workspace.
+- **Entry points**: top header dropdown's "Artist (Album + Publisher)" option (canonical); the New Feed Choice modal's "Artist Setup" button (album mode only) is a shortcut that calls `handleSwitchFeedType('artist')` + closes the modal.
+- **Auto-create logic** (`handleSwitchFeedType('artist')` in `src/App.tsx`): if `state.publisherFeed` is missing, creates one with `remoteItems: [{ feedGuid: albumGuid, feedUrl: '', title: '', medium: 'music' }]`. If `state.album` is missing, creates one with `publisher: { feedGuid: publisherGuid }`. If both exist but the album's `publisher.feedGuid` doesn't match the publisher's `podcastGuid`, dispatches `UPDATE_ALBUM` to fix it. If the publisher exists but its `remoteItems` doesn't include the current album, appends a remoteItem (reconciliation — without this the hosted publisher XML carries a stale cross-link from a prior session). After all reconciliation dispatches, explicitly dispatches `SET_FEED_TYPE: 'artist'` to override the implicit `'album'` / `'publisher'` that `SET_ALBUM` / `SET_PUBLISHER_FEED` set.
+- **`handleStartBlank` artist branch**: when `pendingNewFeedType === 'artist'`, generates two fresh GUIDs and dispatches SET_ALBUM + SET_PUBLISHER_FEED with cross-links + restores feedType. Without this, "New → Start Blank" while in artist mode silently demoted to album mode (the original PR #63 bug).
+- **``** (`src/components/Editor/ArtistEditor.tsx`): thin composition that renders `` + `` inside one outer `main-content/editor-panel` wrapper. Both editors gained an optional `chromeless?: boolean` prop (default false) that, when true, skips their own wrappers — used here so Artist mode has one outer scroll container instead of two nested ones. Section-header bars (indigo for Album, violet for Publisher) demarcate the two halves visually. In artist mode, `` hides the "Publisher Feed (Advanced)" Section entirely (redundant — publisher fields are below). `` hides `CatalogFeedsSection`, `PublisherFeedReminderSection`, `DownloadCatalogSection`, and `PublishSection` (all noise during first-time setup; the artist is building one album + one publisher, not managing a catalog).
+- **``** (`src/components/Editor/ArtistPublishSection.tsx`): inline guided-publish panel at the bottom of the artist editor. Two primary actions:
+ - **Host on MSP — album + publisher (one click)** — calls `hostBothOnMSP(album, publisher, userPubkey, onStep)` from `src/utils/artistPublish.ts`. Gated on Nostr login (the helper uses `createHostedFeedWithNostr` / `updateHostedFeedWithNostr` exclusively — no edit-token path for this combined flow). Disabled until both feeds have a non-empty title (PI silently drops empty-title feeds).
+ - **Download Feed Package (host yourself)** — calls `downloadArtistFeedPackage(album, publisher)` from the same util; emits album XML + publisher XML + a `next-steps.txt` instructions file. Staggered `setTimeout` (400ms/800ms) between downloads to avoid Chrome's multi-download blocker.
+ - **Live step list**: three rows (album-host, publisher-host, verify-index) that transition pending → in-progress → done. Verify polls `/api/pisearch?q={guid}` for both feeds on a backoff schedule (`POLL_DELAYS_MS` in `artistPublish.ts`: 20s/30s/60s/120s, ~10.5 min total budget). Each tick emits a `VerifyProgress` event so the UI can render a countdown ("Checking again in 30s · attempt 5 of 12") and per-feed status.
+ - **Refresh resume**: on mount, the component reads `getHostedFeedInfo()` for both feeds. If both are hosted in this browser, it hydrates `result` with the URLs, marks the host steps ✓ done, and kicks off a fresh verification poll automatically — so a page refresh doesn't trick the user into re-hosting. The Host Both button stays clickable for legitimate re-hosts (label changes to "Re-host both feeds (update with latest XML)").
+ - **Cancellation**: a `CancellationToken` (via `useRef`) is set up per polling session; unmount or a re-click flips `cancelled = true` so background polls don't setState on dead components.
+- **`hostBothOnMSP` cross-link injection** (`src/utils/artistPublish.ts`): the `/api/hosted` endpoint uses `podcastGuid` as the URL `feedId`, so both feeds' hosted URLs are deterministic *before* upload. `hostBothOnMSP` precomputes `buildHostedUrl(album.podcastGuid)` and `buildHostedUrl(publisherFeed.podcastGuid)` and patches them into the album's `` reference and the publisher's `` before serializing the XMLs. Only fills in `feedUrl` when the user hasn't set one manually (preserves user-set externally-hosted publisher URLs). Returns the injected URLs as `injectedAlbumPublisherFeedUrl` / `injectedPublisherRemoteItemFeedUrl` so the caller can dispatch UPDATE actions to reflect them in the in-store feeds (the editor then shows the cross-links the user just shipped).
+- **Test data**: `🧪 Load Test Data` in the hamburger menu (gated behind Show Experimental Features) is artist-aware. In artist mode it dispatches both `generateTestAlbum()` and `generateTestPublisher()` (the latter added in `src/utils/testData.ts`) with cross-linked GUIDs and explicitly restores `feedType: 'artist'` after (since `SET_ALBUM` resets feedType to `'album'`). In other modes the original album-only behavior is preserved.
+
+### First-run onboarding wizard (`new-onboarding-v2` branch)
+A guided flow that takes a brand-new artist from zero to a live hosted feed without touching the full editor. **Branch-only**: `new-onboarding-v2` is a long-lived, separately-deployed build (powers `new.musicsideproject.com`); it is intentionally NOT merged to `master`. Nostr-only auth throughout (no edit-token path). (Supersedes the v1 single-file `ArtistOnboardingWizard.tsx`, which no longer exists on this branch.)
+- **Gate + entry points**: on first visit `App.tsx` renders `` (`src/components/OnboardingPage.tsx`) when `!onboardingStorage.isComplete() && !wizardStorage.isComplete()` — the "Have you used MSP 2.0 before?" gate. The gate has two in-gate screens (`gateView: 'ask' | 'hosting'` state in `OnboardingPage`):
+ - **"No, I'm new"** → a second screen, **"Where will your feed live?"**, that forks on *hosting choice* — **self-host vs MSP-host**, which is orthogonal to Nostr/Lightning (those stay optional on either side):
+ - **"I'll host it myself"** (`onChooseSelfHost`) → marks onboarding complete, closes the gate, drops into the **plain album editor** (`SET_FEED_TYPE 'album'`) — no account, no wizard, no Nostr/Bitcoin shown. They build the feed, Download XML, and self-host. The sequential "album now, publisher later (built via PI search)" flow is the natural fit, because URL cross-linking needs real URLs that only exist after hosting.
+ - **"Let MSP host it for me"** (`onChooseMspHost`) → marks complete, `handleSwitchFeedType('artist')`, opens the wizard (`wizardStorage.markInProgress()` + `setShowArtistWizard(true)`). The both-at-once combined flow is inherently the MSP-host experience (one-click hosting is what mints both URLs and cross-links them instantly).
+ - **"Yes, I've used this before"** (`onChooseReturning`) → marks complete and closes the gate, landing in the editor. Sign-in is **offered, not forced** (no auto-opened modal — a self-host returner must not hit a sign-in wall); the header Sign In is the standing offer, and the **Artist Profile auto-route** (see its section below) sends a signed-in owner of ≥1 hosted feed to their profile.
+ - Also reachable via **New → "New Artist (Guided)"** (`NewFeedChoiceModal`). localStorage: `msp2-onboarding-complete` / `msp2-wizard-complete` (`onboardingStorage` / `wizardStorage` in `src/utils/storage.ts`). **Self-host/MSP-host is purely about where the feed bytes live** — a musician who wants nothing to do with Nostr or Bitcoin/Lightning must be able to use MSP end-to-end via the self-host path (and funding links are the fiat-friendly support option alongside V4V).
+ - **Testing aid**: load `?onboarding=1` once to force the gate on every load regardless of saved completion (sticky via localStorage key `msp:force-onboarding`); `?onboarding=0` disarms. `App.tsx`'s `forceOnboarding` IIFE. No-op in normal use.
+- **``** (`src/components/Onboarding/OnboardingWizard.tsx`, default export) — a slim **full-page dialog** container (`.onboarding-page*` classes in `App.css`, shared with `OnboardingPage`; own Escape-to-close + mount focus). It owns the header/rail/footer chrome, the step-gated `useEffect`s, and `handlePublish`; all wizard state/logic lives in the **`useOnboardingDraft`** hook (`src/components/Onboarding/useOnboardingDraft.ts`). Each step's UI is its own presentational component under `src/components/Onboarding/steps/{Intro,Auth,Publisher,Album,Tracks,Value,Extras,Review}Step.tsx`, each taking the shared `w: OnboardingDraft` (= `ReturnType`) prop; the wizard body is just eight `{step === 'x' && }` lines. Shared pieces: `NostrLoginPanel.tsx` (login UI), `ReviewSummary.tsx` (review card), `CopyableUrlRow.tsx` (label + read-only URL + Copy button + help text, used for the post-publish feed URLs). The **only** wizard exit is the **top-right ✕** (`handleDismiss` = `wizardStorage.markComplete()` + `onComplete()`; Escape does the same), which closes the wizard into the New Artist editor preserving whatever was entered (the wizard writes to the same `feedStore`). There is intentionally no footer "skip" button — it sat flush against the primary Next and invited mis-clicks.
+ - **Steps** (`STEP_ORDER` in `useOnboardingDraft.ts`): `intro · auth · publisher · album · tracks · value · extras · review` (8 steps, same for new and returning artists). Each non-`intro`/`auth` step renders the **same real editor sections** the main editor uses (`AlbumInfoSection`, `AlbumArtworkSection`, `TrackList`, `PersonsSection`, `PublisherInfoSection`, `RecipientsList`, `FundingFields`) so there's one source of truth and full field parity — no parallel simplified fields.
+ - **intro**: static overview page (what MSP does + sign-in→build→publish flow) with a "Get started →" CTA. Plain `.onboarding-step` block, not a ``.
+ - **auth**: a **three-way sign-in chooser** (`AuthStep.tsx`, local `choice` state: `'choose' | 'google' | 'new' | 'have'`) shown when logged out — **Just use Google** (`` → managed keypair, recommended), **Try Nostr — I'm new** (`` — a two-view Primal flow, see the dedicated bullet below), **I already have Nostr** (``: NIP-07 / bunker URI / inline NIP-46 QR). Each path has a **← Back** link to the chooser. `GoogleSignInButton`/`NewToNostrPanel` (`src/components/Onboarding/`) are shared with `NostrConnectModal` (single source of truth). The Google flow is a **full-page redirect**, so App.tsx persists a `wizardStorage.isInProgress()` flag (set when the wizard opens, cleared on close/finish) and initializes `showArtistWizard` from it — the OAuth round-trip re-opens the wizard at its saved step. Once signed in (any method) the step shows the identity card + a **Sign out** button (`nostrStore` `logout`). On sign-in, `onSignedIn` runs the existing-publisher lookup (`loadPublisherFeedsFromNostr`) and shows either a **Continue** (new artist) or a **publisher chooser** (returning artist). The lookup **re-runs on every entry** (re-sign-in after sign-out, Back to auth); `lookingUpRef` only guards overlapping calls, and an npub-change effect drops stale choices on account switch. `choosePublisher` sets the chosen feed and lands on the **publisher** step (pre-filled); `startNewPublisher` creates a locked shell.
+ - **"Try Nostr — I'm new" flow** (`NewToNostrPanel`, two internal views via a `view: 'steps' | 'connect'` state): (1) **steps** — a centered intro line (create your Primal account; download the **app** from the App Store / Google Play — not Primal web, which can't act as the NIP-46 signer) + `` (`src/components/Onboarding/`): a two-column walkthrough of the 5 Primal account-creation screenshots (`src/assets/onboarding/primal-1..5-*.webp`) on the left + a numbered step checklist on the right; the 6th checklist item **"Connect to MSP"** is locked until the 5 setup steps are reached and, when clicked, switches to the connect view. (2) **connect** — a dedicated 3-column page (`primal-menu`/`primal-remote-login`/`primal-connect-login`/`primal-connect-permissions` screenshot ‖ 4 numbered steps ‖ the QR). The QR is `` — a variant that auto-generates the `nostrconnect://` QR and shows only the code + waiting state (no extension/bunker UI). Verified Primal path: **profile avatar → Remote Login** (a QR scanner); MSP shows the QR, Primal scans it, the user picks an account + trust level → Connect. `NostrLoginPanel` (full variant) leads with a QR-first "Scan a QR code to connect" button; `bunker://` paste is a secondary "Advanced" toggle.
+ - **publisher** ("Your artist identity"): `PublisherInfoSection` — note **Artist Name → `author`**, **Catalog Title → `title`**. Shown for everyone, including returning artists (pre-filled with their chosen feed). `ensurePublisherShell` (HARD tie: `locked`/`lockedOwner`) runs only for NEW artists, so picking an existing publisher is never overwritten. The **"Use my Nostr name & photo"** button (`pullProfileFromNostr`) is hidden for **managed (Google) keys** (`connectionMethod === 'managed'`) — a fresh managed keypair has no kind-0 profile to pull.
+ - **value** (`ValueStep.tsx`, "Value / V4V"): Lightning V4V `RecipientsList` **+** the `` "Support link" (`FundingFields` → `album.funding`) — the funding tag lives here, not in extras. For **managed (Google) keys** (`connectionMethod === 'managed'`) the V4V block is hidden entirely (no Lightning wallet) — only the Support link shows; NIP-07/NIP-46 keep the full V4V section.
+ - **extras** (`ExtrasStep.tsx`, "Credits & extras"): Credits/Persons only (funding moved to the value step). `PersonsSection` takes two opt-in props used here: **`hideNpub`** (passed for managed/Google users — hides the per-person Nostr npub field, since they have no Nostr context) and **`myNpub`** (the logged-in Nostr user's npub for non-managed users — renders a **"use mine"** button next to each npub field that fills in your own, for crediting yourself). Both default off, so `TrackList`'s per-track persons and the main editor are unaffected.
+ - **review**: `ReviewSummary` (custom component) — Artist/Publisher block shows Artist Name + Catalog Title as distinct rows; each track with an `enclosureUrl` renders a native **`
+ )}
+
+
+
+```
+
+Modify to hide BOTH the banner and the descriptive paragraph when in Artist mode (the section header alone is enough context, and the publisher fields are immediately visible below the album fields):
+
+```tsx
+
+ {state.feedType !== 'artist' && (
+ state.publisherFeed && album.publisher?.feedGuid === state.publisherFeed.podcastGuid ? (
+ ...
+ ) : (
+ ...
+ )
+ )}
+
+ {/* unchanged */}
+
+
+```
+
+If preferred, hide the entire `` in Artist mode instead — but keep it shown so users in Artist mode can still see / verify the underlying Publisher Feed URL fields the album is linked to.
+
+- [ ] **Step 3: Verify build**
+
+Run: `npm run build`
+
+Expected: clean pass.
+
+- [ ] **Step 4: Manual check**
+
+Run: `npm run dev`
+
+In browser:
+1. Switch to Artist mode
+2. Scroll within the Album block to the "Publisher Feed (Advanced)" section
+3. Expected: no green "Linked to publisher feed: …" banner; no "Link this album to a publisher catalog…" descriptive text; the Publisher Feed URL input and other fields ARE still visible
+4. Switch to Album mode
+5. Expected: the green banner reappears (still cross-linked from Task 3)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/Editor/Editor.tsx
+git commit -m "Hide Editor's Publisher-Feed banner in Artist mode"
+```
+
+---
+
+### Task 7: Fix CatalogFeedsSection dead-code condition
+
+**Files:**
+- Modify: `src/components/Editor/PublisherEditor/CatalogFeedsSection.tsx:259`
+
+The inline "Current album … is in this catalog" / "Add This Album" banner has been dead code since PR #63 — its conditional inverts the publisher-mode check that gates the entire section. Fix surfaces it in both publisher mode and artist mode.
+
+- [ ] **Step 1: Replace the broken conditional**
+
+In `src/components/Editor/PublisherEditor/CatalogFeedsSection.tsx`, line 259:
+
+Before:
+```ts
+const currentAlbum = feedState.feedType !== 'publisher' ? feedState.album : null;
+```
+
+After:
+```ts
+const currentAlbum = feedState.album;
+```
+
+Rationale: `state.album` persists in store across feedType switches (it's a separate field, not feedType-conditional). Reading it directly is safe in publisher mode AND artist mode. The downstream guards (`currentAlbum?.podcastGuid && (...)` at line 274) already handle the empty-album case.
+
+- [ ] **Step 2: Verify build**
+
+Run: `npm run build`
+
+Expected: clean pass.
+
+- [ ] **Step 3: Manual check**
+
+Run: `npm run dev`
+
+In browser:
+1. Switch to Artist mode (auto-creates cross-linked feeds per Task 3)
+2. Scroll down to the Publisher block → Catalog Feeds section
+3. Expected: a green banner reading "Current album … is in this catalog." (Artist Setup pre-populates the catalog with this album in Task 3, so `albumAlreadyInCatalog` is true)
+4. Manually edit `state.album.podcastGuid` (e.g., via React DevTools, or temporarily change it in code) so it no longer matches any catalog entry
+5. Expected: banner flips to indigo "Current album … is not in this catalog yet" with an "Add This Album" button
+
+If you can't easily mutate the GUID for testing, an alternative check: switch to Publisher mode (still works), the same banner should appear.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/components/Editor/PublisherEditor/CatalogFeedsSection.tsx
+git commit -m "Fix CatalogFeedsSection dead-code condition for current-album shortcut"
+```
+
+---
+
+### Task 8: Simplify `handleArtistSetup` to delegate to mode switch
+
+**Files:**
+- Modify: `src/App.tsx:122-136`
+
+The PR's existing `handleArtistSetup` does two manual dispatches. With Task 3 in place, `handleSwitchFeedType('artist')` does the same thing. Simplify.
+
+- [ ] **Step 1: Replace `handleArtistSetup` body**
+
+In `src/App.tsx`, locate `handleArtistSetup` (currently lines 122-136). Replace with:
+
+```tsx
+const handleArtistSetup = () => {
+ handleSwitchFeedType('artist');
+ setShowNewFeedChoiceModal(false);
+};
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `npm run build`
+
+Expected: clean pass. The button in `NewFeedChoiceModal` still calls `onArtistSetup`, which still points at this handler — no other rewiring needed.
+
+- [ ] **Step 3: Manual check**
+
+Run: `npm run dev`
+
+In browser:
+1. Click "New" → "Artist Setup"
+2. Expected: modal closes, feedType becomes Artist, combined editor renders with both feeds cross-linked
+3. Click "New" → "Start Blank"
+4. Expected: still works (creates an empty album, lands in album mode) — Task 8 should not affect this path
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/App.tsx
+git commit -m "Simplify handleArtistSetup to delegate to handleSwitchFeedType"
+```
+
+---
+
+### Task 9: Run full static-check suite
+
+**Files:** none modified.
+
+- [ ] **Step 1: Lint**
+
+Run: `npm run lint`
+
+Expected: 25 errors / 1 warning (identical to master — no new lint issues introduced). If the count is higher, find and fix the new ones; do NOT mass-fix pre-existing tech debt.
+
+- [ ] **Step 2: Build**
+
+Run: `npm run build`
+
+Expected: clean tsc + vite pass.
+
+- [ ] **Step 3: Test**
+
+Run: `npm run test`
+
+Expected: 89 tests pass (no new tests; existing tests cover utility functions that this work doesn't touch).
+
+- [ ] **Step 4: If any check fails**
+
+Stop, fix the root cause, and rerun. Do not proceed to manual testing until all three are green.
+
+---
+
+### Task 10: Manual browser verification (the spec's 9-step checklist)
+
+**Files:** none modified.
+
+Run: `npm run dev` and exercise each numbered scenario from the spec's Testing section. Re-listed here for convenience:
+
+- [ ] **1. Cold start, Artist mode.** Fresh page load, select Artist. Verify combined editor renders. Open React DevTools, confirm `state.album.publisher.feedGuid === state.publisherFeed.podcastGuid`.
+
+- [ ] **2. Persistence.** Edit fields in both halves of the Artist editor. Reload the page. Verify stays in Artist mode (feedTypeStorage persists 'artist') and fields stick.
+
+- [ ] **3. Mode round-trip.** Artist → Album → Artist. Verify no extra feeds created, fields preserved, cross-link intact.
+
+- [ ] **4. Existing album mode round-trip.** Start in Album mode with an existing album that has no publisher link (manually clear `album.publisher` via DevTools, or import an album feed without one). Switch to Artist. Verify the album is preserved and a publisher feed is auto-created and cross-linked.
+
+- [ ] **5. CatalogFeedsSection prompt in Publisher mode.** From Artist mode, switch to Publisher mode via the dropdown. In the Catalog Feeds section, verify the green "Current album … is in this catalog" banner appears.
+
+- [ ] **6. Save → Download Feed Package in Artist mode.** Click Save → choose "Download Feed Package (album + publisher)" → 3 files download. Open each XML and verify cross-referenced GUIDs (`` in album points at publisher's GUID; `` in publisher points at album's GUID).
+
+- [ ] **7. Save → Submit to PodcastIndex in Artist mode.** Verify it submits the album feed URL, same as in album mode.
+
+- [ ] **8. Switch to Video mode from Artist.** Verify video editor appears; the album and publisher feeds remain in store (still in DevTools), untouched.
+
+- [ ] **9. NewFeedChoiceModal → Artist Setup button.** From any mode, click New → Artist Setup. Verify the modal closes, feedType becomes Artist, combined editor renders, both feeds created/preserved.
+
+- [ ] **10. Regression: Album / Video / Publisher modes unchanged.** Quick spot-check that the three pre-existing modes look and behave identically to master. No chrome/layout changes, no extra fields, no console errors.
+
+If any check fails, fix the root cause (don't paper over), commit the fix as a follow-up, and re-verify.
+
+---
+
+### Task 11: Update PR #63 description and push
+
+**Files:** none in repo (GitHub PR body only).
+
+- [ ] **Step 1: Push the branch**
+
+```bash
+git push origin claude/artist-publisher-feed-flow-Ad6B7
+```
+
+- [ ] **Step 2: Update the PR description**
+
+Run:
+```bash
+gh pr edit 63 --body "$(cat <<'EOF'
+Introduces an "Artist Setup" path that creates both an album feed and a
+publisher catalog simultaneously with GUIDs cross-linked, then opens a
+combined editor where the user fills both feeds on a single page.
+
+## What's new
+
+- **Artist feedType (new):** Top dropdown now offers Album / Video /
+ Publisher / Artist (Album + Publisher). Selecting Artist auto-creates
+ any missing feeds with cross-linked GUIDs and renders ArtistEditor,
+ which stacks the existing Album and Publisher editors in one scroll.
+- **Artist Setup button (existing in PR):** The "New" modal's Artist
+ Setup button is preserved as a shortcut — it just switches to Artist
+ mode (the mode switch does the feed creation).
+- **Download Feed Package:** Save → Download Feed Package downloads
+ album XML + publisher XML + next-steps.txt, with cross-referenced
+ GUIDs baked in.
+- **Bug fix:** CatalogFeedsSection's "Add This Album" prompt was dead
+ code (inverted feedType check); now appears as designed.
+
+Existing flows (Album / Video / Publisher) are unchanged.
+
+Spec: docs/superpowers/specs/2026-05-21-combined-artist-editor-design.md
+Plan: docs/superpowers/plans/2026-05-21-combined-artist-editor.md
+EOF
+)"
+```
+
+- [ ] **Step 3: Verify PR is green**
+
+Run: `gh pr view 63 --json statusCheckRollup,mergeable,mergeStateStatus`
+
+Expected: `mergeable: MERGEABLE`, all status checks SUCCESS.
+
+---
+
+## Plan summary
+
+| Task | Files touched | Risk |
+|---|---|---|
+| 1. Extend FeedType + storage | 2 | Low — type-system surface only |
+| 2. Add dropdown option | 1 | Trivial |
+| 3. handleSwitchFeedType branch | 1 | Medium — feed creation logic |
+| 4. chromeless prop | 2 | Low — additive prop, default preserves behavior |
+| 5. ArtistEditor + route | 2 | Low — composition of tested components |
+| 6. Hide redundant banner | 1 | Trivial |
+| 7. Fix dead-code condition | 1 | Trivial — one-line fix |
+| 8. Simplify handleArtistSetup | 1 | Trivial |
+| 9. Static checks | 0 | Verification only |
+| 10. Manual browser test | 0 | Verification only |
+| 11. Push + PR update | 0 | Documentation |
+
+Total estimate: 1–2 hours including manual testing.
diff --git a/docs/superpowers/plans/2026-06-19-google-profile-push.md b/docs/superpowers/plans/2026-06-19-google-profile-push.md
new file mode 100644
index 0000000..92dbebf
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-19-google-profile-push.md
@@ -0,0 +1,390 @@
+# Google Managed-Key Profile Push — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When a Google managed-key user publishes through the onboarding wizard, write their Artist Name + publisher artwork to their (otherwise-blank) Nostr kind-0 profile so the fresh npub shows a real name and avatar across Nostr apps.
+
+**Architecture:** A pure fill-empty merge helper plus a thin kind-0 publish helper, both added to `src/utils/nostrSync.ts` next to the existing `fetchNostrProfile`. The wizard's `publish()` calls them behind a `getConnectionMethod() === 'managed'` gate, best-effort, after the feed publish succeeds. `nostrStore` gains an `updateProfile()` method so the in-app identity card reflects the new values immediately.
+
+**Tech Stack:** React 19 + TypeScript, Vitest 4, nostr-tools, existing `signEventWithTimeout` / `publishEventToRelays` helpers.
+
+## Global Constraints
+
+- TypeScript strict mode; `noUnusedLocals` / `noUnusedParameters` enforced — no unused imports/vars.
+- Never call `signer.signEvent` / `getPublicKey` directly — use `signEventWithTimeout` / `getPublicKeyWithTimeout`.
+- Feature is **managed-method only** (`getConnectionMethod() === 'managed'`). NIP-07 / NIP-46 paths must be untouched.
+- The profile push is **best-effort**: a failure must never block or reject the wizard `publish()` flow.
+- Merge is **non-destructive**: only fill kind-0 fields that are currently empty.
+- Commit messages: imperative tense; end with `Co-Authored-By: Claude Opus 4.8 (1M context) `.
+- Reference spec: `docs/superpowers/specs/2026-06-19-google-profile-push-design.md`.
+
+---
+
+### Task 1: Pure profile-merge helper
+
+**Files:**
+- Modify: `src/utils/nostrSync.ts` (add `mergeProfileFields`, after `fetchNostrProfile` which ends at line 92)
+- Test: `src/utils/nostrSync.test.ts` (create)
+
+**Interfaces:**
+- Consumes: `NostrProfile` interface (already exported from `nostrSync.ts:36`).
+- Produces: `mergeProfileFields(existing: NostrProfile | null, fields: { name?: string; picture?: string }): NostrProfile | null` — returns the merged profile if it adds at least one field, else `null`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/utils/nostrSync.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { mergeProfileFields } from './nostrSync';
+
+describe('mergeProfileFields', () => {
+ it('fills name, display_name, and picture for a null (fresh) profile', () => {
+ const r = mergeProfileFields(null, { name: 'Doerfels', picture: 'https://img/a.png' });
+ expect(r).toEqual({ name: 'Doerfels', display_name: 'Doerfels', picture: 'https://img/a.png' });
+ });
+
+ it('returns null when the existing profile already has name + picture (nothing to fill)', () => {
+ const r = mergeProfileFields(
+ { name: 'Existing', display_name: 'Existing', picture: 'https://old.png', lud16: 'a@b.com' },
+ { name: 'New', picture: 'https://new.png' }
+ );
+ expect(r).toBeNull();
+ });
+
+ it('fills only empty fields and preserves unrelated ones', () => {
+ const r = mergeProfileFields(
+ { picture: 'https://old.png', lud16: 'a@b.com' },
+ { name: 'New', picture: 'https://new.png' }
+ );
+ expect(r).toEqual({
+ name: 'New',
+ display_name: 'New',
+ picture: 'https://old.png',
+ lud16: 'a@b.com',
+ });
+ });
+
+ it('returns null when the supplied fields are empty/whitespace', () => {
+ expect(mergeProfileFields(null, { name: ' ', picture: '' })).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/utils/nostrSync.test.ts`
+Expected: FAIL — `mergeProfileFields` is not exported.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `src/utils/nostrSync.ts`, immediately after the closing `}` of `fetchNostrProfile` (line 92):
+
+```ts
+// Merge artist-provided fields into an existing kind-0 profile, filling only
+// fields that are currently empty (non-destructive — mirrors the opt-in profile
+// pull in the wizard). Returns the merged profile if it adds anything, else null.
+export function mergeProfileFields(
+ existing: NostrProfile | null,
+ fields: { name?: string; picture?: string }
+): NostrProfile | null {
+ const merged: NostrProfile = existing ? { ...existing } : {};
+ const name = (fields.name ?? '').trim();
+ const picture = (fields.picture ?? '').trim();
+ let changed = false;
+ if (name && !merged.name) { merged.name = name; changed = true; }
+ if (name && !merged.display_name) { merged.display_name = name; changed = true; }
+ if (picture && !merged.picture) { merged.picture = picture; changed = true; }
+ return changed ? merged : null;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/utils/nostrSync.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/utils/nostrSync.ts src/utils/nostrSync.test.ts
+git commit -m "Add mergeProfileFields: non-destructive kind-0 profile merge
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+### Task 2: kind-0 publish helper
+
+**Files:**
+- Modify: `src/utils/nostrSync.ts` (add `publishProfileMetadata` after `mergeProfileFields`)
+- Test: `src/utils/nostrSync.test.ts` (extend)
+
+**Interfaces:**
+- Consumes: `hasSigner`, `getPublicKeyWithTimeout`, `signEventWithTimeout` (from `./nostrSigner`, already imported at `nostrSync.ts:13`); `publishEventToRelays`, `DEFAULT_RELAYS` (from `./nostrRelay`, already imported at `nostrSync.ts:6-12`); `NostrEvent` (already imported at `nostrSync.ts:2`).
+- Produces: `publishProfileMetadata(profile: NostrProfile, relays?: string[]): Promise<{ success: boolean; eventId?: string }>`.
+
+- [ ] **Step 1: Write the failing test**
+
+Add the mocks at the TOP of `src/utils/nostrSync.test.ts` (above the existing imports), and a new `describe` block at the bottom. The mocks must cover every export `nostrSync.ts` pulls from `./nostrSigner` and `./nostrRelay`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+
+const { signMock, publishMock } = vi.hoisted(() => ({
+ signMock: vi.fn(async (e: { kind: number; content: string }) => ({ ...e, id: 'fake-event-id', sig: 'fake-sig' })),
+ publishMock: vi.fn(async () => ({ successCount: 1, results: [] })),
+}));
+
+vi.mock('./nostrSigner', () => ({
+ hasSigner: () => true,
+ getPublicKeyWithTimeout: vi.fn(async () => 'pubkeyhex'),
+ signEventWithTimeout: signMock,
+}));
+
+vi.mock('./nostrRelay', () => ({
+ DEFAULT_RELAYS: ['wss://relay.test'],
+ MUSIC_RELAYS: ['wss://relay.test'],
+ connectRelay: vi.fn(),
+ collectEvents: vi.fn(),
+ publishEventToRelays: publishMock,
+}));
+```
+
+Update the existing top import line to pull in the new function:
+
+```ts
+import { mergeProfileFields, publishProfileMetadata } from './nostrSync';
+```
+
+Add this `describe` block at the end of the file:
+
+```ts
+describe('publishProfileMetadata', () => {
+ it('signs a kind-0 event carrying the profile JSON and publishes it', async () => {
+ const res = await publishProfileMetadata({ name: 'Doerfels', display_name: 'Doerfels', picture: 'https://img/a.png' });
+ expect(res.success).toBe(true);
+ expect(res.eventId).toBe('fake-event-id');
+
+ const signedEvent = signMock.mock.calls[0][0];
+ expect(signedEvent.kind).toBe(0);
+ expect(JSON.parse(signedEvent.content)).toMatchObject({
+ name: 'Doerfels',
+ display_name: 'Doerfels',
+ picture: 'https://img/a.png',
+ });
+ expect(publishMock).toHaveBeenCalledTimes(1);
+ });
+});
+```
+
+> Note: the four `mergeProfileFields` tests from Task 1 still run in this file. They don't touch the mocked modules, so they remain green.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/utils/nostrSync.test.ts`
+Expected: FAIL — `publishProfileMetadata` is not exported.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `src/utils/nostrSync.ts`, immediately after `mergeProfileFields`:
+
+```ts
+// Publish the logged-in user's kind-0 profile (NIP-01 metadata). Used to give a
+// freshly-minted managed (Google) keypair a real name + avatar. Best-effort:
+// returns { success: false } rather than throwing so callers never break.
+export async function publishProfileMetadata(
+ profile: NostrProfile,
+ relays = DEFAULT_RELAYS
+): Promise<{ success: boolean; eventId?: string }> {
+ if (!hasSigner()) return { success: false };
+ try {
+ const pubkey = await getPublicKeyWithTimeout();
+ const unsigned: NostrEvent = {
+ kind: 0,
+ pubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [],
+ content: JSON.stringify(profile),
+ };
+ const signed = await signEventWithTimeout(unsigned);
+ const { successCount } = await publishEventToRelays(signed as NostrEvent, relays);
+ return { success: successCount > 0, eventId: (signed as NostrEvent).id };
+ } catch {
+ return { success: false };
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/utils/nostrSync.test.ts`
+Expected: PASS (5 tests total).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/utils/nostrSync.ts src/utils/nostrSync.test.ts
+git commit -m "Add publishProfileMetadata: publish a kind-0 Nostr profile
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+### Task 3: Expose updateProfile on nostrStore
+
+**Files:**
+- Modify: `src/store/nostrStore.tsx` (context type `:100-106`, provider body, context value `:352-353`)
+
+**Interfaces:**
+- Consumes: existing `UPDATE_PROFILE` reducer case (`nostrStore.tsx:66`) and its action payload type `{ displayName?: string; picture?: string; nip05?: string; lud16?: string }` (`nostrStore.tsx:32`).
+- Produces: `updateProfile(payload: { displayName?: string; picture?: string; nip05?: string; lud16?: string }): void` on the `useNostr()` context.
+
+- [ ] **Step 1: Add the method to the context type**
+
+In `src/store/nostrStore.tsx`, extend `NostrContextType` (currently lines 100-106):
+
+```ts
+interface NostrContextType {
+ state: NostrAuthState;
+ login: () => Promise;
+ loginWithNip46: (bunkerUri?: string, onUriGenerated?: (uri: string) => void) => Promise;
+ loginWithGoogle: () => void;
+ updateProfile: (payload: { displayName?: string; picture?: string; nip05?: string; lud16?: string }) => void;
+ logout: () => void;
+}
+```
+
+- [ ] **Step 2: Define the callback in the provider**
+
+In `src/store/nostrStore.tsx`, immediately before the `logout` callback (currently `const logout = useCallback(...)` at line 346):
+
+```ts
+ // Apply local profile fields (used after publishing a kind-0 for managed keys
+ // so the identity card updates without waiting for a relay round-trip).
+ const updateProfile = useCallback((payload: { displayName?: string; picture?: string; nip05?: string; lud16?: string }) => {
+ dispatch({ type: 'UPDATE_PROFILE', payload });
+ }, []);
+```
+
+- [ ] **Step 3: Add it to the provider value**
+
+In `src/store/nostrStore.tsx`, update the context value (line 353):
+
+```tsx
+
+```
+
+- [ ] **Step 4: Verify it type-checks and builds**
+
+Run: `npm run build`
+Expected: build succeeds (tsc + vite), no type errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/store/nostrStore.tsx
+git commit -m "Expose updateProfile() on the Nostr store
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+### Task 4: Wire the managed-only push into the wizard publish() + docs
+
+**Files:**
+- Modify: `src/components/Onboarding/useOnboardingDraft.ts` (imports near the top; `publish()` body, `:308-318`)
+- Modify: `CLAUDE.md` (Nostr Integration → managed keypair note)
+
+**Interfaces:**
+- Consumes: `mergeProfileFields`, `publishProfileMetadata`, `fetchNostrProfile` (from `../../utils/nostrSync`); `getConnectionMethod` (from `../../utils/nostrSigner`); `nostr.updateProfile` (Task 3). The hook already holds `nostr` via `useNostr()` and `pubkey` is in scope inside `publish()` (`useOnboardingDraft.ts:254`); `publisherFeed` (with `.author` and `.imageUrl`) is the local var built at `:301`.
+- Produces: no new exports — behavioral change only.
+
+- [ ] **Step 1: Add the imports**
+
+The hook imports from neither `nostrSync` nor `nostrSigner` today. Add two new import lines in `src/components/Onboarding/useOnboardingDraft.ts`, directly after the existing `import { generateRssFeed } from '../../utils/xmlGenerator';` (line 25):
+
+```ts
+import { fetchNostrProfile, mergeProfileFields, publishProfileMetadata } from '../../utils/nostrSync';
+import { getConnectionMethod } from '../../utils/nostrSigner';
+```
+
+- [ ] **Step 2: Confirm the Nostr context handle**
+
+The hook already holds the context as `const nostr = useNostr();` (line 81), so `nostr.updateProfile(...)` is callable directly. No change needed in this step — the call is added in Step 3.
+
+- [ ] **Step 3: Add the gated push inside publish()**
+
+In `src/components/Onboarding/useOnboardingDraft.ts`, inside `publish()`, after the publisher-feed result is handled and BEFORE `return result;` (currently lines 315-318):
+
+```ts
+ if (result.updatedPublisherFeed) {
+ dispatch({ type: 'SET_PUBLISHER_FEED', payload: result.updatedPublisherFeed });
+ }
+
+ // Managed (Google) keypairs are minted with no kind-0 profile, so the npub
+ // shows as a truncated npub1… everywhere. Give it a real name + avatar from
+ // what the artist just entered. Managed-only (NIP-07/NIP-46 users already
+ // have a profile + the opt-in pull). Best-effort — never blocks publishing.
+ if (getConnectionMethod() === 'managed') {
+ try {
+ const existingProfile = await fetchNostrProfile(pubkey);
+ const merged = mergeProfileFields(existingProfile, {
+ name: publisherFeed.author,
+ picture: publisherFeed.imageUrl,
+ });
+ if (merged) {
+ const profileRes = await publishProfileMetadata(merged);
+ if (profileRes.success) {
+ nostr.updateProfile({
+ displayName: merged.display_name || merged.name,
+ picture: merged.picture,
+ });
+ }
+ }
+ } catch (e) {
+ console.warn('Managed profile push failed (non-blocking):', e);
+ }
+ }
+
+ return result;
+```
+
+> Also add `nostr` (or `nostr.updateProfile`) to the `useCallback` dependency array of `publish()` if the linter flags it — the array currently ends `}, [state.album, state.publisherFeed, nostr.state, isReturningArtist, dispatch]);` (`:322`). Change `nostr.state` to `nostr` there, or append `nostr.updateProfile`.
+
+- [ ] **Step 4: Verify build + lint + tests**
+
+Run: `npm run build && npm run lint && npm run test`
+Expected: build succeeds; lint shows **0 errors** (pre-existing warnings in `ArtistPublishSection.tsx` and `admin/FeedList.tsx` are acceptable); all tests pass (now including the 5 in `nostrSync.test.ts`).
+
+- [ ] **Step 5: Update CLAUDE.md**
+
+In `CLAUDE.md`, under the **Nostr Integration** section, in the **Managed keypair (Google sign-in)** bullet, append a sentence:
+
+```md
+The onboarding wizard also **pushes** a profile for managed keys: on publish, if `getConnectionMethod() === 'managed'`, it writes the publisher's Artist Name (`author`) and artwork (`imageUrl`) to the user's kind-0 profile via `publishProfileMetadata()` (`src/utils/nostrSync.ts`), filling only empty fields (`mergeProfileFields`). This is the inverse of the opt-in profile *pull* and is managed-only — NIP-07/NIP-46 users already have a profile. Best-effort; never blocks publishing.
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/Onboarding/useOnboardingDraft.ts CLAUDE.md
+git commit -m "Push Artist Name + artwork to Nostr profile for managed keys
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Manual Verification (preview deploy, Google session)
+
+After Task 4, push the branch and test on the branch preview (`msp-2-0-git-new-onboarding-v2-…vercel.app/?onboarding=1`), since the Google OAuth backend isn't reachable from local dev:
+
+1. Sign in with **Google** in the wizard (fresh account ideally).
+2. Set an **Artist Name** and upload **publisher artwork**, then complete **Publish**.
+3. Open the npub in a Nostr client (e.g. Primal) and confirm the profile now shows that **name** and **avatar**.
+4. Re-run the wizard / republish and confirm it does **not** overwrite a name/picture you've since edited elsewhere (fill-empty behavior).
+5. Sanity check a NIP-07 or NIP-46 session: publishing must **not** touch their existing profile.
diff --git a/docs/superpowers/plans/2026-06-24-onboarding-landing-flow-phase1.md b/docs/superpowers/plans/2026-06-24-onboarding-landing-flow-phase1.md
new file mode 100644
index 0000000..f392b5c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-24-onboarding-landing-flow-phase1.md
@@ -0,0 +1,377 @@
+# Onboarding Landing Flow (Phase 1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Route new visitors by intent — a "Where will your feed live?" choice (self-host → straight to editor, no account; MSP-host → wizard) — keep the "used MSP before?" gate, soften the returning branch to offer (not force) sign-in, and make the wizard's skip-to-editor exit explicit.
+
+**Architecture:** Pure UI-wiring changes over existing pieces. The gate (`OnboardingPage`) gains a second in-gate screen (the hosting choice) and two new callbacks; `App.tsx` wires those callbacks to existing flows (editor vs wizard); the wizard (`OnboardingWizard`) gains a labeled skip button that reuses its existing `handleDismiss`. No new components, no routing library, no data-model changes.
+
+**Tech Stack:** React 19 + TypeScript, Vite, Vitest. Context stores (`feedStore`, `nostrStore`). No React Router.
+
+## Global Constraints
+
+- **Typecheck with `npm run build` or `npx tsc -b`, never `tsc --noEmit`** — the root tsconfig is references-only and `tsc --noEmit` checks zero files (false green).
+- **Lint with `npm run lint`** — must stay at 0 errors. Two pre-existing warnings (`ArtistPublishSection.tsx:214`, `admin/FeedList.tsx:40`) are expected and unrelated; introduce no new ones.
+- **No component-test harness exists** (no React Testing Library / jsdom). The established pattern for UI changes is pure-function Vitest tests where a pure unit exists + `npm run build`/`npm run lint` + a manual `npm run dev` walkthrough. These three tasks are UI wiring with no extractable pure unit, so each task's "test cycle" is: build → lint → scripted manual verification. Do NOT add RTL/jsdom — that's a separate, out-of-scope change.
+- **Manual gate re-trigger:** load `http://localhost:5173/?onboarding=1` once to force the gate on every load (persists via the `msp:force-onboarding` localStorage flag); load `?onboarding=0` to disarm. This is the documented way to re-test onboarding without clearing localStorage.
+- **TypeScript strict mode** with `noUnusedLocals`/`noUnusedParameters` — remove props/vars you stop using (the build fails otherwise).
+- **Branch:** `onboarding-landing-flow` (already checked out, cut from `new-onboarding-v2`).
+- **Out of scope (do not implement here):** the Phase 2 hostname default (`new.` → wizard / apex → editor), and the funding-vs-Lightning presentation. Both are deferred per the spec.
+
+---
+
+## File Structure
+
+- `src/components/OnboardingPage.tsx` — the gate. Add the in-gate "Where will your feed live?" screen + `onChooseSelfHost`/`onChooseMspHost` props; remove `onChooseFirstTime`.
+- `src/App.tsx` — `AppContent`: wire the two new gate callbacks (self-host → editor; MSP-host → wizard) and soften `onChooseReturning` to not auto-open the sign-in modal.
+- `src/components/Onboarding/OnboardingWizard.tsx` — add an explicit "Skip — I'll host it myself" footer button reusing `handleDismiss`.
+
+Reused CSS (no new styles needed): `.onboarding-gate-actions`, `.onboarding-gate-btn`, `.onboarding-step`, `.onboarding-heading`, `.onboarding-text`, `.onboarding-welcome-icon`, `.step-nav`, `.btn`, `.btn-primary`, `.btn-secondary`.
+
+---
+
+## Task 1: Gate "I'm new" → "Where will your feed live?" choice
+
+**Files:**
+- Modify: `src/components/OnboardingPage.tsx` (props interface ~lines 5-11; signature ~line 19; gate render ~lines 67-98)
+- Modify: `src/App.tsx` (the `OnboardingPage` render block, ~lines 299-326)
+
+**Interfaces:**
+- Produces (OnboardingPage props): `onChooseSelfHost?: () => void`, `onChooseMspHost?: () => void`. Removes `onChooseFirstTime?: () => void`.
+- Consumes: existing `onboardingStorage.markComplete()`, `setShowOnboarding`, `handleSwitchFeedType('artist')`, `wizardStorage.markInProgress()`, `setShowArtistWizard`, and `dispatch` (all already in `AppContent`).
+
+- [ ] **Step 1: Swap the props on `OnboardingPage`**
+
+In `src/components/OnboardingPage.tsx`, replace the `onChooseFirstTime` prop with the two hosting callbacks. Change the interface (around lines 8-11):
+
+```tsx
+ onClose: () => void;
+ startAtGate?: boolean;
+ /** Fired when a returning user picks "Yes, I've used this before" on the gate. */
+ onChooseReturning?: () => void;
+ /** Fired from the "Where will your feed live?" screen — self-host goes straight
+ to the editor (no account, no wizard); MSP-host launches the guided wizard. */
+ onChooseSelfHost?: () => void;
+ onChooseMspHost?: () => void;
+}
+```
+
+And the function signature (line 19):
+
+```tsx
+export function OnboardingPage({ onClose, startAtGate = false, onChooseReturning, onChooseSelfHost, onChooseMspHost }: OnboardingPageProps) {
+```
+
+- [ ] **Step 2: Add the in-gate hosting-choice state**
+
+In `src/components/OnboardingPage.tsx`, just after the existing `const [step, setStep] = useState(...)` (line 17), add:
+
+```tsx
+ // Within the gate (step 0): 'ask' = the "used before?" question; 'hosting' =
+ // the "where will your feed live?" follow-up shown after picking "I'm new".
+ const [gateView, setGateView] = useState<'ask' | 'hosting'>('ask');
+```
+
+- [ ] **Step 3: Rewrite the gate render to two screens**
+
+Replace the entire `{step === 0 && ( … )}` block (lines 67-98) with:
+
+```tsx
+ {step === 0 && gateView === 'ask' && (
+
+
👋
+
Have you used MSP 2.0 before?
+
+ If you're returning, jump straight into the app.
+
+ First time here? We'll point you the right way.
+
+ Let MSP host your feed for you, or make the feed and host it on your
+ own website. You can change your mind anytime.
+
+
+
+
+
+
+
+ )}
+```
+
+- [ ] **Step 4: Wire the callbacks in `App.tsx`**
+
+In `src/App.tsx`, replace the `onChooseFirstTime={() => { … }}` prop on `` (lines 316-324) with the two new handlers:
+
+```tsx
+ onChooseSelfHost={() => {
+ // Self-host: no account, no wizard — straight to the editor. They make the
+ // feed, download the XML, and host it themselves. Nostr/Lightning stay
+ // optional. Force album mode so a brand-new user lands on the album editor.
+ onboardingStorage.markComplete();
+ setShowOnboarding(false);
+ dispatch({ type: 'SET_FEED_TYPE', payload: 'album' });
+ }}
+ onChooseMspHost={() => {
+ // MSP-host: the guided wizard (account via Google/Nostr), ends with a
+ // hosted feed. Mark the gate complete, enter Artist mode, open the wizard.
+ onboardingStorage.markComplete();
+ setShowOnboarding(false);
+ handleSwitchFeedType('artist');
+ wizardStorage.markInProgress();
+ setShowArtistWizard(true);
+ }}
+```
+
+- [ ] **Step 5: Typecheck**
+
+Run: `npx tsc -b`
+Expected: clean (no output, exit 0). If it reports `onChooseFirstTime` unused or missing, confirm you removed it from both the interface and the `App.tsx` usage.
+
+- [ ] **Step 6: Lint**
+
+Run: `npm run lint`
+Expected: `0 errors` (the 2 known warnings only).
+
+- [ ] **Step 7: Manual verification**
+
+Run: `npm run dev`, open `http://localhost:5173/?onboarding=1`.
+- Gate shows "Have you used MSP 2.0 before?" → click **No, I'm new** → screen changes to "Where will your feed live?".
+- Click **← Back** → returns to the "used before?" question.
+- **No, I'm new → I'll host it myself** → gate closes, lands in the **Editor** (Album mode), no sign-in/Nostr/Bitcoin prompt.
+- Reload `?onboarding=1`, **No, I'm new → Let MSP host it for me** → the **Wizard** opens.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/components/OnboardingPage.tsx src/App.tsx
+git commit -m "Add self-host vs MSP-host choice to onboarding gate
+
+The gate's 'I'm new' branch now asks where the feed will live: self-host
+goes straight to the editor (no account, no wizard); MSP-host opens the
+guided wizard. Replaces onChooseFirstTime with onChooseSelfHost/onChooseMspHost.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Task 2: "Yes, I've used this before" offers sign-in instead of forcing it
+
+**Files:**
+- Modify: `src/App.tsx` (the `onChooseReturning` handler, ~lines 304-315)
+
+**Interfaces:**
+- Consumes: `onboardingStorage.markComplete()`, `setShowOnboarding` (both already present).
+- Behavior change: a logged-out returning user lands in the **Editor** (no auto-opened sign-in modal). Sign-in stays available via the header/hamburger; if they sign in and own ≥1 hosted feed, the existing auto-route effect moves them to the Profile.
+
+**Rationale:** a self-host returner must not hit a sign-in wall. The header Sign In is the standing "offer"; the modal is no longer forced open.
+
+- [ ] **Step 1: Simplify the returning handler**
+
+In `src/App.tsx`, replace the `onChooseReturning={() => { … }}` body (lines 304-315) with:
+
+```tsx
+ onChooseReturning={() => {
+ // Returning artist: close the gate and land in the editor. Sign-in is
+ // OFFERED via the header (not forced) so a self-host returner isn't walled.
+ // If a session restores (or they sign in) and owns >=1 hosted feed, the
+ // auto-route effect moves them to their Profile.
+ onboardingStorage.markComplete();
+ setShowOnboarding(false);
+ }}
+```
+
+(This removes the `setNostrConnectReturning(true)` + `setShowNostrConnectModal(true)` calls from this path. Leave the `nostrConnectReturning` state, the hamburger Sign In's `setNostrConnectReturning(false)`, and the `NostrConnectModal returning={...}` prop in place — they remain valid for the header/hamburger sign-in path and a future inline returning-offer. They are not dead: the hamburger path still sets and reads the flag.)
+
+- [ ] **Step 2: Typecheck**
+
+Run: `npx tsc -b`
+Expected: clean. If it flags `nostrConnectReturning` or `setNostrConnectReturning` as unused, confirm the hamburger Sign In button still calls `setNostrConnectReturning(false)` and the modal render still passes `returning={nostrConnectReturning}` — both should remain from prior work.
+
+- [ ] **Step 3: Lint**
+
+Run: `npm run lint`
+Expected: `0 errors` (2 known warnings only).
+
+- [ ] **Step 4: Manual verification**
+
+Run: `npm run dev`, open `http://localhost:5173/?onboarding=1` **while logged out**.
+- Gate → **Yes, I've used this before** → lands directly in the **Editor**; **no sign-in modal pops up**.
+- Confirm a Sign In affordance is still reachable from the header/hamburger.
+- (Owner check) Sign in via the hamburger as an identity that owns ≥1 hosted feed → you are auto-routed to the **Profile** (the existing effect).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/App.tsx
+git commit -m "Offer (not force) sign-in on the returning-artist gate branch
+
+'Yes, I've used this before' now lands in the editor instead of auto-
+opening the sign-in modal, so a self-host returner isn't walled. The
+header Sign In remains the offer; the auto-route effect still moves a
+signed-in owner to their Profile.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Task 3: Make the wizard's skip-to-editor exit explicit
+
+**Files:**
+- Modify: `src/components/Onboarding/OnboardingWizard.tsx` (footer JSX ~lines 237-275; stale comment ~lines 248-249)
+
+**Interfaces:**
+- Consumes: the existing `handleDismiss` (line 75) — `wizardStorage.markComplete(); onComplete();` — which closes the wizard and lands in the editor, preserving whatever the user entered (the wizard writes to the same `feedStore` as the editor).
+- Produces: a visible "Skip — I'll host it myself" footer button. No new props.
+
+**Rationale:** the wizard already exits to the editor via the top-right ✕, but it reads as "close", not "skip to editor / I'll host it myself". This adds an explicit, labeled affordance.
+
+- [ ] **Step 1: Add the explicit skip button to the footer**
+
+In `src/components/Onboarding/OnboardingWizard.tsx`, change the start of the `footer` JSX (lines 238-244) from:
+
+```tsx
+
+ {/* Left: explicit skip-to-editor exit, then Back. Skipping keeps whatever the
+ user has entered (the wizard writes to the same feedStore as the editor). */}
+
+ {canGoBack && (
+
+ )}
+```
+
+- [ ] **Step 2: Fix the now-stale footer comment**
+
+In the same file, update the comment at lines ~248-249. Change:
+
+```tsx
+ {/* Right: primary action (Next / Publish / Open). The top-right X handles
+ skip-to-editor, so there's no separate Skip button. */}
+```
+
+to:
+
+```tsx
+ {/* Right: primary action (Next / Publish / Open). Skip-to-editor lives on the
+ left (and the top-right X does the same). */}
+```
+
+- [ ] **Step 3: Typecheck**
+
+Run: `npx tsc -b`
+Expected: clean.
+
+- [ ] **Step 4: Lint**
+
+Run: `npm run lint`
+Expected: `0 errors` (2 known warnings only).
+
+- [ ] **Step 5: Manual verification**
+
+Run: `npm run dev`, open `http://localhost:5173/?onboarding=1` → **No, I'm new → Let MSP host it for me** to open the wizard.
+- Advance a step or two and type something into a field (e.g. the album title on the Album step).
+- Click **Skip — I'll host it myself** → the wizard closes and you land in the **Editor**, with what you typed still present.
+- Re-open the wizard and confirm the top-right ✕ still also exits to the editor.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/Onboarding/OnboardingWizard.tsx
+git commit -m "Add explicit 'Skip — I'll host it myself' exit to the wizard
+
+The wizard already exits to the editor via the top-right X; this adds a
+labeled footer button so an MSP-host newcomer who decides to self-host
+isn't stuck. Reuses handleDismiss; entered fields are preserved.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+---
+
+## Final verification (after all three tasks)
+
+- [ ] `npm run build` → clean (tsc + vite).
+- [ ] `npm run lint` → 0 errors (2 known warnings).
+- [ ] `npx vitest run` → all pass (no tests changed, but confirm nothing regressed).
+- [ ] Full manual walk of the four flows from the spec's verification section:
+ - New + self-host → editor, no account prompts.
+ - New + MSP-host → wizard, with working skip-to-editor.
+ - Returning, logged out → editor, sign-in offered not forced.
+ - Returning, logged in + owns feeds → Profile.
+
+## Notes / deferred
+
+- **Phase 2 (hostname default):** when ready, add a `window.location.hostname` check feeding the landing decision (`new.` → wizard default, apex → editor default). Not in this plan.
+- **Funding-vs-Lightning presentation:** separate follow-up spec (memory `project_nostr-bitcoin-optional`).
+- The `OnboardingPage` tour steps (`step` 1-3 + questionnaire) are unreachable from the gate now (the "I'm new" button goes to the hosting choice, not the tour). They were already effectively unreachable after the "Getting Started" menu item was removed. Left in place intentionally — removing them is out of scope and unrelated.
diff --git a/docs/superpowers/specs/2026-05-21-combined-artist-editor-design.md b/docs/superpowers/specs/2026-05-21-combined-artist-editor-design.md
new file mode 100644
index 0000000..6b6b857
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-21-combined-artist-editor-design.md
@@ -0,0 +1,226 @@
+# Combined Artist Editor — Design Spec
+
+**Date:** 2026-05-21
+**Status:** Approved (pending implementation plan)
+**Related:** PR #63 (Artist Setup flow), which this design supersedes the UX of.
+
+## Context
+
+The Artist Setup flow added in PR #63 creates two cross-linked feeds — an album feed and a publisher catalog — at the same time. The user lands in album mode and must manually switch to publisher mode (and back) to finish setup. That mode-switch is the friction this design removes.
+
+We want first-time artists to fill in everything for both feeds **on a single page**, without ever using the feedType dropdown during setup.
+
+## Approach
+
+Add a new `'artist'` feedType. The top header dropdown becomes a four-item list:
+
+```
+Album / Video / Publisher / Artist (Album + Publisher)
+```
+
+Selecting Artist renders a new `` component that stacks the existing Album editor sections above the existing Publisher editor sections in one scroll. Both feeds remain as separate entities in store (`state.album` + `state.publisherFeed`) with cross-referenced `podcastGuid` fields. Each section dispatches to its own slice of state — no shared inputs in the MVP. Field duplication (e.g., owner email appears in both halves) is accepted in exchange for reusing the existing section components verbatim.
+
+This is **MVP-shaped on purpose**. A second iteration could de-duplicate shared fields into single "Identity / Value / Funding" sections that dispatch to both feeds. That's out of scope for this spec.
+
+## Scope
+
+### In scope
+
+- Extending `FeedType` to include `'artist'` and propagating to the feedType dropdown, persistence, and routing
+- Creating `` that renders the existing album-editor sections above the existing publisher-editor sections
+- Auto-creation of missing feeds when the user first switches to Artist mode (so the dropdown itself becomes a valid entry point with no prior setup)
+- Rewiring the existing "Artist Setup" button in `NewFeedChoiceModal` to be a shortcut that switches to Artist mode (the mode-switch handler does the feed creation)
+- Save modal in Artist mode: the existing package option (`Download Feed Package (album + publisher)`) becomes visible because `!isPublisherMode` is true and the cross-link check already exists. Other save destinations operate on the album, unchanged.
+- Fixing the inverted-condition bug at `CatalogFeedsSection.tsx:259` discovered during PR #63 review
+- Hiding the "Go to Publisher" green banner in `Editor.tsx` when `feedType === 'artist'` (redundant — publisher sections are already on the same page)
+
+### Out of scope
+
+- Smart de-duplicated sections (single "Artist Identity" block writing to both feeds). Deferred to v2.
+- Tracking field linkage between the two feeds. Fields are fully independent.
+- Migration of existing cross-linked album+publisher feed pairs (created by PR #63's current Artist Setup) into Artist mode. Users with such pairs see the new dropdown item; selecting it just opens the combined view over their existing data. No data migration needed.
+- Other save destinations learning about Artist mode. They keep targeting the album, same as today.
+
+## Architecture
+
+### Type changes
+
+```ts
+// src/types/feed.ts
+export type FeedType = 'album' | 'video' | 'publisher' | 'artist';
+```
+
+Re-exported from `src/store/feedStore.tsx` (existing pattern).
+
+### Storage
+
+`feedTypeStorage` (in `feedStore.tsx`) loads and persists feedType. Its allowed-values guard must include `'artist'` so a refresh in Artist mode survives.
+
+### Routing
+
+`src/App.tsx:292` currently:
+```tsx
+{state.feedType === 'publisher' ? : }
+```
+
+Becomes:
+```tsx
+{state.feedType === 'publisher' ?
+ : state.feedType === 'artist' ?
+ : }
+```
+
+### `` (new)
+
+Path: `src/components/Editor/ArtistEditor.tsx`.
+
+Renders two stacked blocks with visual separators:
+
+1. **Album block** — invokes the existing `` component (renamed/exported appropriately, or its inner JSX inlined; see "Open question" below).
+2. **Publisher block** — invokes the existing `` component (or its inner JSX inlined).
+
+Visual treatment: each block is preceded by a sticky section header (e.g., `══ ALBUM ══` / `══ PUBLISHER ══`) so the user knows which feed they're editing. Header is a thin colored bar with the medium icon and short helper text ("These fields go into your album RSS feed", etc.).
+
+#### Open question for implementation
+
+`` and `` today own the `main-content / editor-panel` chrome (margins, padding, scroll container). When stacked inside `` we don't want two scrollable panels — we want one outer scroll. The implementation plan should choose between:
+
+(a) Adding a `chromeless` prop to `` and `` so they can be rendered inside another panel; or
+(b) Inlining their inner JSX in `` (DRY trade-off — sections still come from the same files, just composed in a new wrapper).
+
+Option (a) is preferred because it keeps `` thin and avoids forking the section composition.
+
+### State management
+
+No reducer changes are strictly required — both `state.album` and `state.publisherFeed` already exist in the store. The combined editor reads/writes them through the same actions the existing editors use.
+
+`handleSwitchFeedType` in `App.tsx` gets a new branch for the `'artist'` case:
+
+```ts
+case 'artist': {
+ const needsAlbum = !state.album?.podcastGuid;
+ const needsPublisher = !state.publisherFeed?.podcastGuid;
+ const albumGuid = state.album?.podcastGuid ?? crypto.randomUUID();
+ const publisherGuid = state.publisherFeed?.podcastGuid ?? crypto.randomUUID();
+
+ if (needsPublisher) {
+ dispatch({ type: 'SET_PUBLISHER_FEED', payload: {
+ ...createEmptyPublisherFeed(),
+ podcastGuid: publisherGuid,
+ remoteItems: [{ feedGuid: albumGuid, feedUrl: '', title: '', medium: 'music' }]
+ }});
+ }
+ if (needsAlbum) {
+ dispatch({ type: 'SET_ALBUM', payload: {
+ ...createEmptyAlbum(),
+ podcastGuid: albumGuid,
+ publisher: { feedGuid: publisherGuid }
+ }});
+ } else if (state.album.publisher?.feedGuid !== publisherGuid) {
+ // Album exists but isn't cross-linked yet — link it
+ dispatch({ type: 'UPDATE_ALBUM', payload: { publisher: { feedGuid: publisherGuid } } });
+ }
+ dispatch({ type: 'SET_FEED_TYPE', payload: 'artist' });
+ break;
+}
+```
+
+Key behavior:
+- Both feeds get auto-created with cross-linked GUIDs if missing
+- Pre-existing feeds are preserved; only the missing side(s) are created
+- An existing album without a publisher link gets the link added without overwriting other album fields
+- `SET_ALBUM` and `SET_PUBLISHER_FEED` both set `feedType` themselves, so we explicitly `SET_FEED_TYPE: 'artist'` afterward to override
+
+### `NewFeedChoiceModal` simplification
+
+PR #63 added an "Artist Setup" button that calls `handleArtistSetup` (which directly dispatches both feeds). With the new dropdown entry point, that button's `onClick` simplifies to:
+
+```ts
+const handleArtistSetup = () => {
+ handleSwitchFeedType('artist'); // does the auto-create + mode switch
+ setShowNewFeedChoiceModal(false);
+};
+```
+
+The current `handleArtistSetup` body (the manual two-dispatch logic) is removed — the logic lives in `handleSwitchFeedType`'s `'artist'` branch now.
+
+### Save modal
+
+The package option's visibility guard:
+```ts
+!isPublisherMode && publisherFeed && album.publisher?.feedGuid === publisherFeed.podcastGuid
+```
+
+`isPublisherMode` is `feedType === 'publisher'`, which is false in Artist mode → the option appears. No change to the SaveModal.
+
+For all other Save destinations in Artist mode (Local Storage, Download XML, Submit to PodcastIndex, Host on MSP, etc.), behavior is identical to album mode — they read `album` from props, which `App.tsx` passes correctly in non-video modes.
+
+### CatalogFeedsSection bug fix
+
+`src/components/Editor/PublisherEditor/CatalogFeedsSection.tsx:259`:
+```ts
+// before (broken — feedType is ALWAYS 'publisher' inside this section, so currentAlbum is always null)
+const currentAlbum = feedState.feedType !== 'publisher' ? feedState.album : null;
+
+// after
+const currentAlbum = feedState.album;
+```
+
+`state.album` persists in store across feedType switches, so reading it directly is safe regardless of mode. In Artist mode the section sees the same album the user is currently editing above; in Publisher mode it sees whichever album was last loaded.
+
+### Editor.tsx Go-to-Publisher banner
+
+The green "Linked to publisher feed" banner block (`Editor.tsx:674–697`) is suppressed when `feedType === 'artist'` because the publisher sections are already visible directly below the album sections on the same page. The fallback descriptive `
` (lines 698–701) is also suppressed in Artist mode — the section heading is enough context.
+
+## Data flow
+
+```
+User selects "Artist" from top dropdown
+ → handleSwitchFeedType('artist')
+ → reducer creates missing album/publisher with cross-linked GUIDs
+ → state.feedType = 'artist'
+ → App renders
+ → ArtistEditor renders then
+ → each section dispatches normal album / publisher actions
+ → Save modal: "Download Feed Package" option visible (cross-link condition met)
+ → user clicks Save → Package → existing case 'package' in SaveModal generates both XMLs + next-steps.txt
+```
+
+## Error handling
+
+Nothing new. The existing editors already handle their own error states (empty feed, missing GUIDs, etc.). The only new failure mode is `crypto.randomUUID()` throwing — irrelevant in practice (all supported browsers ship `crypto.randomUUID`; if it's missing, the entire app fails to load earlier).
+
+## Testing
+
+Manual checklist (no new automated tests required; the changes are composition of existing tested components):
+
+1. **Cold start, Artist mode:** Fresh load, switch top dropdown to Artist. Expect: combined editor renders, album and publisher feeds both exist in store with `album.publisher.feedGuid === publisherFeed.podcastGuid`.
+2. **Persistence:** In Artist mode, edit some fields in both halves, reload the page. Expect: stays in Artist mode (feedTypeStorage round-trips); fields persist.
+3. **Mode round-trip:** Artist → Album → Artist. Expect: same data, no duplication, no extra publisher feeds created.
+4. **Mode round-trip with existing album:** Start in Album mode with an existing album that has no publisher link. Switch to Artist. Expect: album is preserved (title/tracks etc. unchanged), a publisher feed is created, cross-link is set on `album.publisher.feedGuid`.
+5. **CatalogFeedsSection prompt (publisher mode):** Open the existing PR-style flow (Artist Setup button → mode switches to Artist → switch dropdown to Publisher manually). Expect: the green "Current album is in this catalog" banner now appears (was dead code before this design).
+6. **Save → Download Feed Package in Artist mode:** Expect: 3 files download (album XML, publisher XML, next-steps.txt) with cross-referenced GUIDs.
+7. **Save → Submit to PodcastIndex in Artist mode:** Expect: same as in album mode — submits the album feed URL.
+8. **Switch to Video mode from Artist:** Expect: video feed editor appears; album and publisher feeds remain in store untouched.
+9. **NewFeedChoiceModal → Artist Setup button:** Expect: modal closes, feedType becomes 'artist', combined editor renders, both feeds created.
+
+Existing tests in `xmlGenerator.test.ts`, `xmlParser.test.ts`, etc. should pass unchanged.
+
+## Touch list (for implementation plan)
+
+- `src/types/feed.ts` — add `'artist'` to `FeedType` union
+- `src/store/feedStore.tsx` — re-export the updated `FeedType`; ensure `feedTypeStorage` accepts `'artist'`
+- `src/App.tsx` — route `'artist'` to ``; extend `handleSwitchFeedType` with the `'artist'` branch; simplify `handleArtistSetup` to just call `handleSwitchFeedType('artist')`; add `'Artist (Album + Publisher)'` to the feedType dropdown UI
+- `src/components/Editor/ArtistEditor.tsx` — new component, stacks Album + Publisher editor blocks with section headers
+- `src/components/Editor/Editor.tsx` — accept optional `chromeless` prop; suppress publisher banner in Artist mode
+- `src/components/Editor/PublisherEditor/index.tsx` — accept optional `chromeless` prop
+- `src/components/Editor/PublisherEditor/CatalogFeedsSection.tsx` — fix inverted condition at line 259
+- `src/components/modals/NewFeedChoiceModal.tsx` — no changes (button stays; only its handler shrinks in App.tsx)
+
+## Verification
+
+After implementation:
+1. `npm run lint` — no new errors beyond the 25 pre-existing
+2. `npm run build` — clean tsc pass
+3. `npm run test` — all 89 tests green
+4. `npm run dev` and exercise the 9-step manual checklist above
diff --git a/docs/superpowers/specs/2026-06-19-google-profile-push-design.md b/docs/superpowers/specs/2026-06-19-google-profile-push-design.md
new file mode 100644
index 0000000..969dc32
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-19-google-profile-push-design.md
@@ -0,0 +1,113 @@
+# Push Artist Name + artwork to Nostr profile (Google managed keys)
+
+**Date:** 2026-06-19
+**Branch:** new-onboarding-v2
+**Status:** Approved design
+
+## Context
+
+The onboarding wizard ties a Nostr identity to the artist's publisher feed. When a
+returning Nostr user signs in, the wizard already offers an opt-in **pull**
+(`pullProfileFromNostr` in `useOnboardingDraft.ts:189`, "Use my Nostr name & photo")
+that fills the publisher `author` / `title` / `imageUrl` form fields from the user's
+existing kind-0 profile (`user.displayName` / `user.picture`).
+
+Google managed-keypair users (`connectionMethod === 'managed'`) are different: their
+npub is freshly minted by the server at sign-in and has **no kind-0 profile at all**, so
+they show up as a truncated `npub1…` across every Nostr app. They have nothing to pull.
+
+This feature is the **inverse push**: take what the managed user typed into the wizard
+(Artist Name + publisher artwork) and write it to their Nostr profile so the fresh npub
+gets a real name and avatar everywhere.
+
+## Scope
+
+- **Only** when `getConnectionMethod() === 'managed'`. NIP-07 and NIP-46 are never
+ touched — they keep the existing opt-in pull. Rationale: NIP-07 users already have a
+ profile; NIP-46/Primal users get one from Primal. Only the Google keypair is born blank.
+- **Out of scope:** pushing the lightning address (`lud16`) or any field other than name +
+ picture; any new UI beyond an optional success-screen confirmation line.
+
+## Behavior
+
+Publish a Nostr **kind-0** profile event with:
+
+- `name` **and** `display_name` ← publisher `author` (Artist Name)
+- `picture` ← publisher `imageUrl` (publisher artwork)
+
+**Non-destructive merge (mirrors the pull):** fetch the user's current kind-0 via the
+existing `fetchNostrProfile()`, then only fill fields that are currently empty
+(`name = existing.name || author`, `display_name = existing.display_name || author`,
+`picture = existing.picture || imageUrl`). A fresh managed npub has no profile, so all
+three set cleanly. A re-run, or anything already present (including unrelated fields like
+`lud16`/`about`/`nip05`), is preserved. Skip the publish entirely if there is nothing new
+to write (both name and picture already populated, or the form values are empty).
+
+**Timing:** runs at publish time inside the wizard's `publish()`
+(`useOnboardingDraft.ts:253`), behind the managed-only gate. Managed signing is
+local/instant, so there is no extra signer prompt. It is **best-effort**: a relay-publish
+failure is logged (`console.warn`) and does **not** block the feed from publishing
+(wrapped so its error can't reject the publish flow).
+
+**After success:** update the in-app identity card to reflect the new name/picture. The
+hook's `dispatch` is the **feedStore** reducer, but `UPDATE_PROFILE` is a **nostrStore**
+action that is not currently exposed. So `nostrStore` gains a small `updateProfile(payload)`
+context method (it already has the `UPDATE_PROFILE` reducer case — this just wires a
+callback to it, mirroring `login`/`logout`), and the wizard calls it. This is a
+nice-to-have; if `updateProfile` is undesirable to add, the card self-corrects on the next
+session restore (which re-fetches kind-0), so the UI update can be dropped without
+affecting the core feature.
+
+## Components
+
+1. **`publishProfileMetadata(profile, relays?)`** — new helper in
+ `src/utils/nostrSync.ts`, placed next to its read-twin `fetchNostrProfile()`. Builds the
+ kind-0 event (`{ kind: 0, pubkey, created_at, tags: [], content: JSON.stringify(profile) }`),
+ signs with the existing `signEventWithTimeout`, publishes via the existing
+ `publishEventToRelays` to `DEFAULT_RELAYS`. Returns `{ success, eventId? }`. Gets the
+ pubkey via `getPublicKeyWithTimeout()` like the other write helpers in this file.
+
+2. **Gated call in `publish()`** (`useOnboardingDraft.ts`) — after the feed publish
+ succeeds, if `getConnectionMethod() === 'managed'`: fetch existing profile, compute the
+ merged `{ name, display_name, picture }`, and if it adds anything, call
+ `publishProfileMetadata(...)`, then `nostr.updateProfile({ displayName, picture })`
+ (see below). All wrapped in try/catch so it never breaks publishing.
+
+3. **`updateProfile` on `nostrStore`** — new context method exposing the existing
+ `UPDATE_PROFILE` reducer case (`{ displayName?, picture?, nip05?, lud16? }`), mirroring
+ how `login` / `logout` are exposed. Consumed by the wizard for the immediate card update.
+
+## Data flow
+
+```
+publisher.author ─┐
+publisher.imageUrl ─┤→ merge with fetchNostrProfile(pubkey) (fill-empty) →
+ │ publishProfileMetadata({name, display_name, picture})
+ │ → signEventWithTimeout(kind 0) → publishEventToRelays(DEFAULT_RELAYS)
+ └→ on success: dispatch UPDATE_PROFILE → identity card updates
+```
+
+## Error handling
+
+- No pubkey / not managed → skip silently (no-op).
+- Nothing new to write → skip (don't spam relays with an identical profile).
+- Sign/publish throws → `console.warn`, swallow; feed publish proceeds and succeeds.
+
+## Testing
+
+- **Unit (`nostrSync` helper):** mock the signer + relay publish; assert the kind-0 event
+ content carries name/display_name/picture and that an existing-profile merge fills only
+ empty fields. (Mirror the style of existing `nostrSync`-adjacent tests.)
+- **Manual (preview deploy, Google session):** sign in with Google in the wizard, set
+ Artist Name + publisher artwork, publish, then confirm in a Nostr client (e.g. Primal)
+ that the npub now shows that name and avatar. Re-run to confirm it doesn't clobber an
+ edited profile.
+
+## Alternatives considered (rejected)
+
+- **Push when leaving the publisher step** — fires earlier but adds a separate signing
+ moment; no benefit since publish-time is when values are finalized.
+- **Server-side default profile at `google-callback`** — impossible; the artist name
+ doesn't exist at sign-in.
+- **Always overwrite name/picture from the form** — simpler but could clobber a managed
+ profile the user edited elsewhere; fill-empty is safer and matches the pull's semantics.
diff --git a/docs/superpowers/specs/2026-06-20-managed-key-authoritative-profile.md b/docs/superpowers/specs/2026-06-20-managed-key-authoritative-profile.md
new file mode 100644
index 0000000..244ba96
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-20-managed-key-authoritative-profile.md
@@ -0,0 +1,62 @@
+# Managed-key Nostr profile: make page-3 artist info authoritative on publish
+
+**Date:** 2026-06-20
+**Branch:** `new-onboarding-v2`
+**Status:** Approved
+
+## Context
+
+A Google managed-keypair user starts with an empty Nostr kind-0 profile ("just a
+keypair and nothing else"), whereas a NIP-07/NIP-46 user brings an existing profile that
+MSP pulls in. On publish, MSP already pushes the wizard's **Artist Name** (`publisherFeed.author`,
+page 3) and **publisher art** (`publisherFeed.imageUrl`, page 3) into the kind-0 for
+managed keys (`useOnboardingDraft.ts:325`, gated `getConnectionMethod() === 'managed'`).
+
+The gap: that push uses `mergeProfileFields`, which is **fill-empty / non-destructive**.
+So the first publish sets name + art correctly, but if the artist later edits page 3 and
+re-publishes, the kind-0 already has values and is **not updated** — the Nostr profile
+goes stale.
+
+## Goal
+
+For managed (Google) keys, make page 3 **authoritative**: every publish sets the kind-0
+`name`/`display_name` ← Artist Name and `picture` ← publisher art, so edits propagate.
+NIP-07/NIP-46 keep the non-destructive behavior (they never run this push).
+
+## Changes
+
+### 1. `mergeProfileFields` — `src/utils/nostrSync.ts`
+Add optional third arg `opts?: { overwrite?: boolean }`, default `false`.
+- `overwrite: false` (default): unchanged — fills only empty `name`/`display_name`/`picture`.
+- `overwrite: true`: set `name`/`display_name` ← `name` and `picture` ← `picture` whenever a
+ value is provided **and differs** from the existing kind-0 value. Only mark `changed`
+ (and thus publish) when something actually differs, so a no-edit re-publish doesn't emit a
+ redundant kind-0 event.
+
+### 2. Managed publish branch — `src/components/Onboarding/useOnboardingDraft.ts:328`
+Pass `{ overwrite: true }`:
+```ts
+const merged = mergeProfileFields(
+ existingProfile,
+ { name: publisherFeed.author, picture: publisherFeed.imageUrl },
+ { overwrite: true },
+);
+```
+No other change to the branch — it's already managed-only and best-effort.
+
+### 3. Test — `src/utils/nostrSync.test.ts`
+Add `mergeProfileFields` overwrite cases:
+- overwrite replaces an existing `name`/`display_name`/`picture` with new values;
+- overwrite returns `null` when the provided values equal the existing ones (no redundant publish);
+- default (no opts) behavior unchanged (existing tests still pass).
+
+## Out of scope
+- Seeding the profile at sign-up (decided: on-publish timing is fine).
+- Changing NIP-07/NIP-46 profile handling.
+- `about`/`nip05`/`lud16` fields (only name + picture are in scope).
+
+## Verification
+- `npm run test` — `nostrSync.test.ts` passes (old + new cases).
+- `npm run lint` (0 errors) and `npm run build` (tsc + vite) succeed.
+- Manual: Google sign-in → fill page 3 → publish (profile shows artist name + art) →
+ edit artist name/art → re-publish → kind-0 reflects the edit.
diff --git a/docs/superpowers/specs/2026-06-20-move-funding-to-value-step.md b/docs/superpowers/specs/2026-06-20-move-funding-to-value-step.md
new file mode 100644
index 0000000..58d6694
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-20-move-funding-to-value-step.md
@@ -0,0 +1,48 @@
+# Move the funding tag from Credits/extras (step 7) to the Value page (step 6)
+
+**Date:** 2026-06-20
+**Branch:** `new-onboarding-v2`
+**Status:** Approved
+
+## Context
+
+The Podcasting 2.0 funding tag (`` — a support URL + text) is currently
+collected in the wizard's **Extras step (7, "Credits & extras")**, below Credits/Persons,
+as two generically-labeled inputs ("URL" / "Text"). That's the wrong home: funding isn't a
+credit or a person — it's a "how fans support you" mechanism, so it belongs with V4V on the
+**Value step (6)**. This also gives Google managed users (who have no Lightning wallet) a
+visible, zero-setup support option right where they'd look for it.
+
+## Goal
+
+Move the funding fields from step 7 to step 6, clearly labeled as a support link. No data
+or type changes — still `album.funding` → ``.
+
+## Changes
+
+### 1. `src/components/Onboarding/steps/ValueStep.tsx`
+Below the existing V4V `RecipientsList`, add a labeled "Support link" block:
+- A heading **"Support link"** + one-line helper: *"A page where fans can support you
+ (Patreon, a tip jar, your site) — works without Lightning."*
+- The existing ` dispatch({ type: 'UPDATE_ALBUM', payload: { funding: f } })} />` (same binding it has in ExtrasStep today).
+
+### 2. `src/components/Onboarding/steps/ExtrasStep.tsx`
+Remove the `` block and its `
` wrapper. The
+step becomes just the intro line + Credits/Persons. Drop the now-unused `FundingFields`
+import.
+
+### 3. No other changes
+`album.funding`, the `Funding` type, `UPDATE_ALBUM`, XML generation, and `ReviewSummary`
+(which reads `album.funding`) are untouched — the field just moves in the UI.
+
+## Out of scope
+- Lightning/V4V behavior, auto-provisioned wallets, skipping the Value step for Google
+ users — separate decisions, not part of this move.
+- Publisher-level funding (`PublisherFundingSection`) — unrelated; the wizard funding is
+ album-level.
+
+## Verification
+- `npm run lint` (0 errors) and `npm run build` succeed.
+- Walk the wizard: step 6 (Value) now shows V4V recipients + the "Support link" funding
+ fields; step 7 (Credits & extras) shows only Credits/Persons (no funding).
+- Enter a funding URL on step 6 → step 8 (Review) still lists it.
diff --git a/docs/superpowers/specs/2026-06-20-primal-signup-carousel-design.md b/docs/superpowers/specs/2026-06-20-primal-signup-carousel-design.md
new file mode 100644
index 0000000..49f4435
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-20-primal-signup-carousel-design.md
@@ -0,0 +1,80 @@
+# Primal signup carousel — onboarding "New to Nostr" panel
+
+**Date:** 2026-06-20
+**Branch:** `new-onboarding-v2`
+**Status:** Approved
+
+## Context
+
+The onboarding wizard's "Try Nostr — I'm new" path (`NewToNostrPanel`) walks a
+first-timer through creating a Nostr identity in Primal, then connecting it to MSP via a
+remote signer (NIP-46). Today that walkthrough is a text-only numbered step list. We have
+real screenshots of Primal's iOS account-creation flow, so we're replacing the
+account-creation half of the step list with a visual carousel. The connect half stays
+live-visual via the existing QR (the user scans MSP's `nostrconnect://` QR with Primal).
+
+## Goal
+
+Show the actual Primal signup as a swipeable, captioned screenshot carousel so a new user
+sees exactly what to do, then connects with the QR-first remote-signer flow already in
+`NostrLoginPanel`.
+
+## Locked decisions (unchanged)
+
+- Primal-only walkthrough; no other app names.
+- iOS + Android wording (screenshots are iOS; captions stay generic where possible).
+- Connect step leads with scanning MSP's QR.
+
+## Assets
+
+5 screenshots copied from Photos and converted to WebP (`cwebp -q 80`, ~144K total) into
+`src/assets/onboarding/`:
+
+| File | Screen | Caption |
+|---|---|---|
+| `primal-1-create-account.webp` | Create Account | Add a display name and photo — Primal generates your Nostr keys for you. |
+| `primal-2-follow-people.webp` | Follow People | Pick a few topics to follow (optional). |
+| `primal-3-account-preview.webp` | Account Preview | Review your new profile. |
+| `primal-4-account-created.webp` | Success! | Keep **Save to iCloud Keychain** on so your key is backed up. |
+| `primal-5-profile.webp` | Profile | 🎉 You're on Nostr — now connect it to MSP. |
+
+## Components
+
+### `PrimalSignupCarousel.tsx` (new, `src/components/Onboarding/`)
+Self-contained presentational carousel.
+- Local `index` state (0–4). Renders the active image + caption, `‹`/`›` arrow buttons,
+ `n/5` counter, and a row of dot buttons.
+- Imports the 5 images as modules into a local `SLIDES` array (`{ src, alt, caption }`).
+- Fixed image frame height so the panel doesn't reflow between slides.
+- Accessibility: `alt` per image, `aria-label` on arrows ("Previous"/"Next"), dots are
+ real `