diff --git a/docs/plans/2026-01-02-onboarding-flow.md b/docs/plans/2026-01-02-onboarding-flow.md
new file mode 100644
index 0000000..45a1a17
--- /dev/null
+++ b/docs/plans/2026-01-02-onboarding-flow.md
@@ -0,0 +1,661 @@
+# Onboarding Flow Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Redirect first-time users to an onboarding page after OAuth, prefilling their Grain profile form with their Bluesky profile data.
+
+**Architecture:** After OAuth callback, check if `socialGrainActorProfile` exists. If not, redirect to `/onboarding`. The onboarding component fetches `appBskyActorProfile` to prefill displayName, description, and avatar. Users can save or skip; both create a profile record to mark them as onboarded.
+
+**Tech Stack:** Lit web components, GraphQL via Quickslice client, existing avatar crop component
+
+---
+
+## Task 1: Add Profile Queries to grain-api.js
+
+**Files:**
+- Modify: `src/services/grain-api.js`
+
+**Step 1: Add hasGrainProfile method**
+
+Add this method to the GrainApiService class (after `resolveHandle` method, around line 1123):
+
+```javascript
+ async hasGrainProfile(client) {
+ const result = await client.query(`
+ query {
+ viewer {
+ socialGrainActorProfileByDid {
+ displayName
+ }
+ }
+ }
+ `);
+ return !!result.viewer?.socialGrainActorProfileByDid;
+ }
+```
+
+**Step 2: Add getBlueskyProfile method**
+
+Add this method after `hasGrainProfile`:
+
+```javascript
+ async getBlueskyProfile(client) {
+ const result = await client.query(`
+ query {
+ viewer {
+ did
+ handle
+ appBskyActorProfileByDid {
+ displayName
+ description
+ avatar { url ref mimeType size }
+ }
+ }
+ }
+ `);
+
+ const viewer = result.viewer;
+ const profile = viewer?.appBskyActorProfileByDid;
+ const avatar = profile?.avatar;
+
+ return {
+ did: viewer?.did || '',
+ handle: viewer?.handle || '',
+ displayName: profile?.displayName || '',
+ description: profile?.description || '',
+ avatarUrl: avatar?.url || '',
+ avatarBlob: avatar ? {
+ $type: 'blob',
+ ref: { $link: avatar.ref },
+ mimeType: avatar.mimeType,
+ size: avatar.size
+ } : null
+ };
+ }
+```
+
+**Step 3: Commit**
+
+```bash
+git add src/services/grain-api.js
+git commit -m "feat: add hasGrainProfile and getBlueskyProfile queries"
+```
+
+---
+
+## Task 2: Add Profile Mutations to mutations.js
+
+**Files:**
+- Modify: `src/services/mutations.js`
+
+**Step 1: Add updateProfile method**
+
+Add this method to the MutationsService class (after `updateAvatar` method, around line 200):
+
+```javascript
+ async updateProfile(input) {
+ const client = auth.getClient();
+
+ await client.mutate(`
+ mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) {
+ updateSocialGrainActorProfile(rkey: $rkey, input: $input) {
+ uri
+ }
+ }
+ `, { rkey: 'self', input });
+
+ await auth.refreshUser();
+ }
+
+ async createEmptyProfile() {
+ return this.updateProfile({
+ displayName: null,
+ description: null
+ });
+ }
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/services/mutations.js
+git commit -m "feat: add updateProfile and createEmptyProfile mutations"
+```
+
+---
+
+## Task 3: Register Onboarding Route
+
+**Files:**
+- Modify: `src/components/pages/grain-app.js`
+
+**Step 1: Add import**
+
+Add import at line 19 (after `grain-oauth-callback.js`):
+
+```javascript
+import './grain-onboarding.js';
+```
+
+**Step 2: Add route registration**
+
+Add route registration at line 69 (before the oauth/callback route):
+
+```javascript
+ .register('/onboarding', 'grain-onboarding')
+```
+
+**Step 3: Commit**
+
+```bash
+git add src/components/pages/grain-app.js
+git commit -m "feat: register /onboarding route"
+```
+
+---
+
+## Task 4: Create Onboarding Component
+
+**Files:**
+- Create: `src/components/pages/grain-onboarding.js`
+
+**Step 1: Create the component**
+
+Create `src/components/pages/grain-onboarding.js`:
+
+```javascript
+import { LitElement, html, css } from 'lit';
+import { router } from '../../router.js';
+import { auth } from '../../services/auth.js';
+import { grainApi } from '../../services/grain-api.js';
+import { mutations } from '../../services/mutations.js';
+import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js';
+import '../atoms/grain-icon.js';
+import '../atoms/grain-button.js';
+import '../atoms/grain-input.js';
+import '../atoms/grain-textarea.js';
+import '../atoms/grain-avatar.js';
+import '../atoms/grain-spinner.js';
+import '../molecules/grain-form-field.js';
+import '../molecules/grain-avatar-crop.js';
+
+export class GrainOnboarding extends LitElement {
+ static properties = {
+ _loading: { state: true },
+ _saving: { state: true },
+ _error: { state: true },
+ _displayName: { state: true },
+ _description: { state: true },
+ _avatarUrl: { state: true },
+ _avatarBlob: { state: true },
+ _newAvatarDataUrl: { state: true },
+ _showAvatarCrop: { state: true },
+ _cropImageUrl: { state: true }
+ };
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ max-width: var(--feed-max-width);
+ min-height: 100%;
+ padding-bottom: 80px;
+ background: var(--color-bg-primary);
+ align-self: center;
+ }
+ .header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-xl) var(--space-sm) var(--space-lg);
+ text-align: center;
+ }
+ h1 {
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ margin: 0;
+ }
+ .subtitle {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ }
+ .content {
+ padding: 0 var(--space-sm);
+ }
+ @media (min-width: 600px) {
+ .content {
+ padding: 0;
+ }
+ }
+ .avatar-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: var(--space-lg);
+ }
+ .avatar-wrapper {
+ position: relative;
+ cursor: pointer;
+ }
+ .avatar-overlay {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--color-bg-primary);
+ border: 2px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-primary);
+ }
+ .avatar-preview {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ background: var(--color-bg-elevated);
+ }
+ input[type="file"] {
+ display: none;
+ }
+ .actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ padding: var(--space-lg) var(--space-sm);
+ border-top: 1px solid var(--color-border);
+ margin-top: var(--space-lg);
+ }
+ @media (min-width: 600px) {
+ .actions {
+ padding-left: 0;
+ padding-right: 0;
+ }
+ }
+ .skip-button {
+ background: none;
+ border: none;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ padding: var(--space-sm);
+ text-align: center;
+ }
+ .skip-button:hover {
+ color: var(--color-text-primary);
+ text-decoration: underline;
+ }
+ .error {
+ color: var(--color-danger, #dc3545);
+ font-size: var(--font-size-sm);
+ padding: var(--space-sm);
+ text-align: center;
+ }
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-md);
+ padding: var(--space-xl);
+ min-height: 300px;
+ }
+ .loading p {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ }
+ `;
+
+ constructor() {
+ super();
+ this._loading = true;
+ this._saving = false;
+ this._error = null;
+ this._displayName = '';
+ this._description = '';
+ this._avatarUrl = '';
+ this._avatarBlob = null;
+ this._newAvatarDataUrl = null;
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ }
+
+ async connectedCallback() {
+ super.connectedCallback();
+
+ if (!auth.isAuthenticated) {
+ router.replace('/');
+ return;
+ }
+
+ await this.#checkAndLoad();
+ }
+
+ async #checkAndLoad() {
+ try {
+ const client = auth.getClient();
+
+ // Check if user already has a Grain profile
+ const hasProfile = await grainApi.hasGrainProfile(client);
+ if (hasProfile) {
+ this.#redirectToDestination();
+ return;
+ }
+
+ // Fetch Bluesky profile to prefill
+ const bskyProfile = await grainApi.getBlueskyProfile(client);
+ this._displayName = bskyProfile.displayName;
+ this._description = bskyProfile.description;
+ this._avatarUrl = bskyProfile.avatarUrl;
+ this._avatarBlob = bskyProfile.avatarBlob;
+ } catch (err) {
+ console.error('Failed to load profile data:', err);
+ } finally {
+ this._loading = false;
+ }
+ }
+
+ #redirectToDestination() {
+ const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
+ sessionStorage.removeItem('oauth_return_url');
+ router.replace(returnUrl);
+ }
+
+ #handleDisplayNameChange(e) {
+ this._displayName = e.detail.value.slice(0, 64);
+ }
+
+ #handleDescriptionChange(e) {
+ this._description = e.detail.value.slice(0, 256);
+ }
+
+ #handleAvatarClick() {
+ this.shadowRoot.querySelector('#avatar-input').click();
+ }
+
+ async #handleAvatarChange(e) {
+ const input = e.target;
+ const file = input.files?.[0];
+ if (!file) return;
+
+ input.value = '';
+
+ try {
+ const dataUrl = await readFileAsDataURL(file);
+ const resized = await resizeImage(dataUrl, {
+ width: 2000,
+ height: 2000,
+ maxSize: 900000
+ });
+ this._cropImageUrl = resized.dataUrl;
+ this._showAvatarCrop = true;
+ } catch (err) {
+ console.error('Failed to process avatar:', err);
+ this._error = 'Failed to process image';
+ }
+ }
+
+ #handleCropCancel() {
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ }
+
+ #handleCrop(e) {
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ this._newAvatarDataUrl = e.detail.dataUrl;
+ this._avatarBlob = null;
+ }
+
+ get #displayedAvatarUrl() {
+ if (this._newAvatarDataUrl) return this._newAvatarDataUrl;
+ return this._avatarUrl;
+ }
+
+ async #handleSave() {
+ if (this._saving) return;
+
+ this._saving = true;
+ this._error = null;
+
+ try {
+ const input = {
+ displayName: this._displayName.trim() || null,
+ description: this._description.trim() || null
+ };
+
+ if (this._newAvatarDataUrl) {
+ const base64Data = this._newAvatarDataUrl.split(',')[1];
+ const blob = await mutations.uploadBlob(base64Data, 'image/jpeg');
+ input.avatar = {
+ $type: 'blob',
+ ref: { $link: blob.ref },
+ mimeType: blob.mimeType,
+ size: blob.size
+ };
+ } else if (this._avatarBlob) {
+ input.avatar = this._avatarBlob;
+ }
+
+ await mutations.updateProfile(input);
+ this.#redirectToDestination();
+ } catch (err) {
+ console.error('Failed to save profile:', err);
+ this._error = err.message || 'Failed to save profile. Please try again.';
+ } finally {
+ this._saving = false;
+ }
+ }
+
+ async #handleSkip() {
+ if (this._saving) return;
+
+ this._saving = true;
+ this._error = null;
+
+ try {
+ await mutations.createEmptyProfile();
+ this.#redirectToDestination();
+ } catch (err) {
+ console.error('Failed to skip onboarding:', err);
+ this._error = err.message || 'Something went wrong. Please try again.';
+ } finally {
+ this._saving = false;
+ }
+ }
+
+ render() {
+ if (this._loading) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+ ${this._error ? html`${this._error}
` : ''}
+
+
+
+
+ ${this.#displayedAvatarUrl ? html`
+

+ ` : html`
+
+ `}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save & Continue
+
+
+
+
+ `;
+ }
+}
+
+customElements.define('grain-onboarding', GrainOnboarding);
+```
+
+**Step 2: Verify the component compiles**
+
+Run: `npm run dev`
+Navigate to: `http://localhost:5173/onboarding`
+Expected: Page loads (redirects to home if not authenticated or already has profile)
+
+**Step 3: Commit**
+
+```bash
+git add src/components/pages/grain-onboarding.js
+git commit -m "feat: add onboarding component with Bluesky profile prefill"
+```
+
+---
+
+## Task 5: Modify OAuth Callback to Check Profile
+
+**Files:**
+- Modify: `src/services/auth.js`
+
+**Step 1: Add import for grainApi**
+
+Add at line 2 (after router import):
+
+```javascript
+import { grainApi } from './grain-api.js';
+```
+
+**Step 2: Update the redirect callback handler**
+
+Replace lines 19-26 in `src/services/auth.js` with:
+
+```javascript
+ // Handle OAuth callback if present
+ if (window.location.search.includes('code=')) {
+ await this.#client.handleRedirectCallback();
+
+ // Check if user has a Grain profile
+ const hasProfile = await grainApi.hasGrainProfile(this.#client);
+
+ if (!hasProfile) {
+ // First-time user - redirect to onboarding
+ window.location.replace('/onboarding');
+ return;
+ }
+
+ // Existing user - redirect to their destination
+ const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
+ sessionStorage.removeItem('oauth_return_url');
+ window.location.replace(returnUrl);
+ return;
+ }
+```
+
+**Step 3: Commit**
+
+```bash
+git add src/services/auth.js
+git commit -m "feat: redirect first-time users to onboarding after OAuth"
+```
+
+---
+
+## Task 6: Test Complete Flow
+
+**Files:**
+- None (manual testing)
+
+**Step 1: Test new user flow**
+
+1. Clear localStorage/sessionStorage (or use incognito)
+2. Navigate to `/explore`
+3. Click login, authenticate with Bluesky
+4. Expected: Redirected to `/onboarding` with Bluesky profile prefilled
+5. Click "Save & Continue"
+6. Expected: Redirected to `/explore` (original return URL)
+
+**Step 2: Test skip flow**
+
+1. Use a different account without Grain profile
+2. Go through OAuth
+3. On onboarding, click "Skip for now"
+4. Expected: Redirected to return URL, profile record created
+
+**Step 3: Test returning user flow**
+
+1. Log out and log back in with same account
+2. Expected: Goes directly to return URL (no onboarding)
+
+**Step 4: Test manual onboarding access**
+
+1. While logged in with existing profile, navigate to `/onboarding`
+2. Expected: Immediately redirected to home
+
+---
+
+## Summary
+
+| Task | Description | Files |
+|------|-------------|-------|
+| 1 | Add profile queries | `grain-api.js` |
+| 2 | Add profile mutations | `mutations.js` |
+| 3 | Register route | `grain-app.js` |
+| 4 | Create onboarding component | `grain-onboarding.js` (new) |
+| 5 | Modify OAuth callback | `auth.js` |
+| 6 | Test complete flow | Manual testing |
diff --git a/src/components/pages/grain-app.js b/src/components/pages/grain-app.js
index b02e373..545a739 100644
--- a/src/components/pages/grain-app.js
+++ b/src/components/pages/grain-app.js
@@ -17,6 +17,7 @@ import './grain-terms.js';
import './grain-privacy.js';
import './grain-copyright.js';
import './grain-oauth-callback.js';
+import './grain-onboarding.js';
import '../organisms/grain-header.js';
import '../organisms/grain-bottom-nav.js';
@@ -66,6 +67,7 @@ export class GrainApp extends LitElement {
.register('/create/descriptions', 'grain-image-descriptions')
.register('/explore', 'grain-explore')
.register('/notifications', 'grain-notifications')
+ .register('/onboarding', 'grain-onboarding')
.register('/oauth/callback', 'grain-oauth-callback')
.register('*', 'grain-timeline')
.connect(outlet);
diff --git a/src/components/pages/grain-onboarding.js b/src/components/pages/grain-onboarding.js
new file mode 100644
index 0000000..decc67b
--- /dev/null
+++ b/src/components/pages/grain-onboarding.js
@@ -0,0 +1,391 @@
+import { LitElement, html, css } from 'lit';
+import { router } from '../../router.js';
+import { auth } from '../../services/auth.js';
+import { grainApi } from '../../services/grain-api.js';
+import { mutations } from '../../services/mutations.js';
+import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js';
+import '../atoms/grain-icon.js';
+import '../atoms/grain-button.js';
+import '../atoms/grain-input.js';
+import '../atoms/grain-textarea.js';
+import '../atoms/grain-avatar.js';
+import '../atoms/grain-spinner.js';
+import '../molecules/grain-form-field.js';
+import '../molecules/grain-avatar-crop.js';
+
+export class GrainOnboarding extends LitElement {
+ static properties = {
+ _loading: { state: true },
+ _saving: { state: true },
+ _error: { state: true },
+ _displayName: { state: true },
+ _description: { state: true },
+ _avatarUrl: { state: true },
+ _avatarBlob: { state: true },
+ _newAvatarDataUrl: { state: true },
+ _showAvatarCrop: { state: true },
+ _cropImageUrl: { state: true }
+ };
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ max-width: var(--feed-max-width);
+ min-height: 100%;
+ padding-bottom: 80px;
+ background: var(--color-bg-primary);
+ align-self: center;
+ }
+ .header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-xl) var(--space-sm) var(--space-lg);
+ text-align: center;
+ }
+ h1 {
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ margin: 0;
+ }
+ .subtitle {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ }
+ .content {
+ padding: 0 var(--space-sm);
+ }
+ @media (min-width: 600px) {
+ .content {
+ padding: 0;
+ }
+ }
+ .avatar-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: var(--space-lg);
+ }
+ .avatar-wrapper {
+ position: relative;
+ cursor: pointer;
+ }
+ .avatar-overlay {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--color-bg-primary);
+ border: 2px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-primary);
+ }
+ .avatar-preview {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ background: var(--color-bg-elevated);
+ }
+ input[type="file"] {
+ display: none;
+ }
+ .actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ padding: var(--space-lg) var(--space-sm);
+ border-top: 1px solid var(--color-border);
+ margin-top: var(--space-lg);
+ }
+ @media (min-width: 600px) {
+ .actions {
+ padding-left: 0;
+ padding-right: 0;
+ }
+ }
+ .skip-button {
+ background: none;
+ border: none;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ padding: var(--space-sm);
+ text-align: center;
+ }
+ .skip-button:hover {
+ color: var(--color-text-primary);
+ text-decoration: underline;
+ }
+ .error {
+ color: var(--color-danger, #dc3545);
+ font-size: var(--font-size-sm);
+ padding: var(--space-sm);
+ text-align: center;
+ }
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-md);
+ padding: var(--space-xl);
+ min-height: 300px;
+ }
+ .loading p {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ }
+ `;
+
+ constructor() {
+ super();
+ this._loading = true;
+ this._saving = false;
+ this._error = null;
+ this._displayName = '';
+ this._description = '';
+ this._avatarUrl = '';
+ this._avatarBlob = null;
+ this._newAvatarDataUrl = null;
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ }
+
+ async connectedCallback() {
+ super.connectedCallback();
+
+ if (!auth.isAuthenticated) {
+ router.replace('/');
+ return;
+ }
+
+ await this.#checkAndLoad();
+ }
+
+ async #checkAndLoad() {
+ try {
+ const client = auth.getClient();
+
+ // Check if user already has a Grain profile
+ const hasProfile = await grainApi.hasGrainProfile(client);
+ if (hasProfile) {
+ this.#redirectToDestination();
+ return;
+ }
+
+ // Fetch Bluesky profile to prefill
+ const bskyProfile = await grainApi.getBlueskyProfile(client);
+ this._displayName = bskyProfile.displayName;
+ this._description = bskyProfile.description;
+ this._avatarUrl = bskyProfile.avatarUrl;
+ this._avatarBlob = bskyProfile.avatarBlob;
+ } catch (err) {
+ console.error('Failed to load profile data:', err);
+ } finally {
+ this._loading = false;
+ }
+ }
+
+ #redirectToDestination() {
+ const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
+ sessionStorage.removeItem('oauth_return_url');
+ router.replace(returnUrl);
+ }
+
+ #handleDisplayNameChange(e) {
+ this._displayName = e.detail.value.slice(0, 64);
+ }
+
+ #handleDescriptionChange(e) {
+ this._description = e.detail.value.slice(0, 256);
+ }
+
+ #handleAvatarClick() {
+ this.shadowRoot.querySelector('#avatar-input').click();
+ }
+
+ async #handleAvatarChange(e) {
+ const input = e.target;
+ const file = input.files?.[0];
+ if (!file) return;
+
+ input.value = '';
+
+ try {
+ const dataUrl = await readFileAsDataURL(file);
+ const resized = await resizeImage(dataUrl, {
+ width: 2000,
+ height: 2000,
+ maxSize: 900000
+ });
+ this._cropImageUrl = resized.dataUrl;
+ this._showAvatarCrop = true;
+ } catch (err) {
+ console.error('Failed to process avatar:', err);
+ this._error = 'Failed to process image';
+ }
+ }
+
+ #handleCropCancel() {
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ }
+
+ #handleCrop(e) {
+ this._showAvatarCrop = false;
+ this._cropImageUrl = null;
+ this._newAvatarDataUrl = e.detail.dataUrl;
+ this._avatarBlob = null;
+ }
+
+ get #displayedAvatarUrl() {
+ if (this._newAvatarDataUrl) return this._newAvatarDataUrl;
+ return this._avatarUrl;
+ }
+
+ async #handleSave() {
+ if (this._saving) return;
+
+ this._saving = true;
+ this._error = null;
+
+ try {
+ const input = {
+ createdAt: new Date().toISOString()
+ };
+
+ const displayName = this._displayName.trim();
+ const description = this._description.trim();
+ if (displayName) input.displayName = displayName;
+ if (description) input.description = description;
+
+ if (this._newAvatarDataUrl) {
+ const base64Data = this._newAvatarDataUrl.split(',')[1];
+ const blob = await mutations.uploadBlob(base64Data, 'image/jpeg');
+ input.avatar = {
+ $type: 'blob',
+ ref: { $link: blob.ref },
+ mimeType: blob.mimeType,
+ size: blob.size
+ };
+ } else if (this._avatarBlob) {
+ input.avatar = this._avatarBlob;
+ }
+
+ await mutations.updateProfile(input);
+ this.#redirectToDestination();
+ } catch (err) {
+ console.error('Failed to save profile:', err);
+ this._error = err.message || 'Failed to save profile. Please try again.';
+ } finally {
+ this._saving = false;
+ }
+ }
+
+ async #handleSkip() {
+ if (this._saving) return;
+
+ this._saving = true;
+ this._error = null;
+
+ try {
+ await mutations.createEmptyProfile();
+ this.#redirectToDestination();
+ } catch (err) {
+ console.error('Failed to skip onboarding:', err);
+ this._error = err.message || 'Something went wrong. Please try again.';
+ } finally {
+ this._saving = false;
+ }
+ }
+
+ render() {
+ if (this._loading) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+ ${this._error ? html`${this._error}
` : ''}
+
+
+
+
+ ${this.#displayedAvatarUrl ? html`
+

+ ` : html`
+
+ `}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save & Continue
+
+
+
+
+ `;
+ }
+}
+
+customElements.define('grain-onboarding', GrainOnboarding);
diff --git a/src/services/auth.js b/src/services/auth.js
index ac727ba..20fd76c 100644
--- a/src/services/auth.js
+++ b/src/services/auth.js
@@ -1,5 +1,6 @@
import { createQuicksliceClient } from 'quickslice-client-js';
import { router } from '../router.js';
+import { grainApi } from './grain-api.js';
class AuthService {
#client = null;
@@ -19,6 +20,17 @@ class AuthService {
// Handle OAuth callback if present
if (window.location.search.includes('code=')) {
await this.#client.handleRedirectCallback();
+
+ // Check if user has a Grain profile
+ const hasProfile = await grainApi.hasGrainProfile(this.#client);
+
+ if (!hasProfile) {
+ // First-time user - redirect to onboarding
+ window.location.replace('/onboarding');
+ return;
+ }
+
+ // Existing user - redirect to their destination
const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
sessionStorage.removeItem('oauth_return_url');
window.location.replace(returnUrl);
diff --git a/src/services/grain-api.js b/src/services/grain-api.js
index 81b0de0..ad5e069 100644
--- a/src/services/grain-api.js
+++ b/src/services/grain-api.js
@@ -1121,6 +1121,53 @@ class GrainApiService {
return did;
}
+
+ async hasGrainProfile(client) {
+ const result = await client.query(`
+ query {
+ viewer {
+ socialGrainActorProfileByDid {
+ displayName
+ }
+ }
+ }
+ `);
+ return !!result.viewer?.socialGrainActorProfileByDid;
+ }
+
+ async getBlueskyProfile(client) {
+ const result = await client.query(`
+ query {
+ viewer {
+ did
+ handle
+ appBskyActorProfileByDid {
+ displayName
+ description
+ avatar { url ref mimeType size }
+ }
+ }
+ }
+ `);
+
+ const viewer = result.viewer;
+ const profile = viewer?.appBskyActorProfileByDid;
+ const avatar = profile?.avatar;
+
+ return {
+ did: viewer?.did || '',
+ handle: viewer?.handle || '',
+ displayName: profile?.displayName || '',
+ description: profile?.description || '',
+ avatarUrl: avatar?.url || '',
+ avatarBlob: avatar ? {
+ $type: 'blob',
+ ref: { $link: avatar.ref },
+ mimeType: avatar.mimeType,
+ size: avatar.size
+ } : null
+ };
+ }
}
export const grainApi = new GrainApiService();
diff --git a/src/services/mutations.js b/src/services/mutations.js
index 6af0aa9..0a5a8ef 100644
--- a/src/services/mutations.js
+++ b/src/services/mutations.js
@@ -198,6 +198,26 @@ class MutationsService {
// Refresh user data
await auth.refreshUser();
}
+
+ async updateProfile(input) {
+ const client = auth.getClient();
+
+ await client.mutate(`
+ mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) {
+ updateSocialGrainActorProfile(rkey: $rkey, input: $input) {
+ uri
+ }
+ }
+ `, { rkey: 'self', input });
+
+ await auth.refreshUser();
+ }
+
+ async createEmptyProfile() {
+ return this.updateProfile({
+ createdAt: new Date().toISOString()
+ });
+ }
}
export const mutations = new MutationsService();