+ {/* eslint-disable-next-line react/no-unknown-property -- scoped keyframes for progress bar */}
+
+
+
+
+
+ {milestoneBurstKey > 0 && progress > 0 && (
+
+ )}
+
+
+
+
0 ? undefined : 0,
+ }}
+ />
+
+ {milestoneBurstKey > 0 && progress > 0 && (
+
+
+
+ {SWIPE_PROGRESS_MILESTONE_SPARK_OFFSETS.map(
+ (sp, sparkIndex) => (
+
+ ),
+ )}
+
+
+ )}
+
+
+
+ {getSwipeOnboardingGuidanceMessage(progressCount, copyVariant)}
+
+
+ {progressValue} / {SWIPE_ONBOARDING_REFINE_TARGET}
+
+
+
+
+ );
+}
diff --git a/packages/webapp/components/onboarding/SwipePersonaIntro.tsx b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx
new file mode 100644
index 00000000000..5ac8380e43b
--- /dev/null
+++ b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx
@@ -0,0 +1,87 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { PersonaSelector } from '@dailydotdev/shared/src/components/onboarding/PersonaSelector';
+import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings';
+
+interface SwipePersonaIntroProps {
+ onSelectionChange: (selected: GQLPersona[]) => void;
+ onStart: () => void;
+ onSkip: () => void;
+ loading: boolean;
+ loadingLabel?: string;
+}
+
+const surfaceClassName =
+ 'w-full overflow-hidden rounded-[2rem] border border-border-subtlest-secondary bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]';
+
+const panelClassName =
+ 'rounded-[1.5rem] border border-border-subtlest-tertiary bg-surface-float';
+
+export function SwipePersonaIntro({
+ onSelectionChange,
+ onStart,
+ onSkip,
+ loading,
+ loadingLabel,
+}: SwipePersonaIntroProps): ReactElement {
+ return (
+
+
+
+
+ Personalize your daily.dev feed
+
+
+
+ What kind of dev are you?
+
+
+ Pick up to three roles. We'll line up posts you'll
+ actually want to read.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts
new file mode 100644
index 00000000000..62d16c35688
--- /dev/null
+++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts
@@ -0,0 +1,308 @@
+import { useCallback, useRef, useState } from 'react';
+import type { OnboardingSwipeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal';
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import { PostType } from '@dailydotdev/shared/src/graphql/posts';
+import type { PostSummary } from '../lib/swipingBackendApi';
+import { discoverPosts } from '../lib/swipingBackendApi';
+
+// Scoring constants — ported from PostSwiper.tsx
+const LIKE_SCORE = 1.5;
+const DISLIKE_SCORE = -1;
+const ADD_THRESHOLD = 3;
+const SATURATE_THRESHOLD = 4.5;
+const REMOVE_THRESHOLD = -5;
+const IGNORE_AFTER = 10;
+const SATURATE_AFTER = 5;
+const PREFETCH_AFTER_SWIPES = 3;
+const BATCH_SIZE = 8;
+
+function toSwipeCard(post: PostSummary): OnboardingSwipeCard {
+ return {
+ id: post.postId,
+ summary: post.summary,
+ title: post.title,
+ image: null,
+ tags: post.tags,
+ source: {
+ name: 'daily.dev',
+ image: null,
+ },
+ };
+}
+
+function toBookmarkablePost(post: PostSummary): Post {
+ return {
+ id: post.postId,
+ title: post.title,
+ summary: post.summary,
+ permalink: post.url,
+ commentsPermalink: post.url,
+ image: '',
+ tags: post.tags,
+ bookmarked: false,
+ type: PostType.Article,
+ };
+}
+
+interface StartDeckOptions {
+ prompt?: string;
+ initialTags?: string[];
+}
+
+interface AdaptiveSwipeDeck {
+ cards: OnboardingSwipeCard[];
+ getBookmarkablePost: (cardId: string) => Post | undefined;
+ isLoading: boolean;
+ startDeck: (options?: StartDeckOptions) => Promise
;
+ handleSwipe: (direction: 'left' | 'right', cardId: string) => void;
+ retryFetch: () => Promise;
+ selectedTags: string[];
+ rightSwipedPostIds: Set;
+}
+
+export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck {
+ const [cards, setCards] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [rightSwipedPostIds, setRightSwipedPostIds] = useState>(
+ () => new Set(),
+ );
+
+ // Refs for mutable state that persists across batches
+ const tagScoresRef = useRef>({});
+ const tagSeenCountRef = useRef>({});
+ const seenIdsRef = useRef>(new Set());
+ const likedTitlesRef = useRef([]);
+ const initialPromptRef = useRef('');
+ const prefetchedRef = useRef(null);
+ const swipesInBatchRef = useRef(0);
+ const prefetchTriggeredRef = useRef(false);
+ const selectedTagsRef = useRef([]);
+ // Keep a PostSummary lookup so we can access tags/title on swipe
+ const postLookupRef = useRef