diff --git a/.gitignore b/.gitignore
index 8174566fc2..7a5b2e5aaa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,4 +59,4 @@ plans/*.md
# Claude Code runtime state (hooks/skills/settings remain tracked)
.claude/scheduled_tasks.lock
-.claude/drafts/
\ No newline at end of file
+.claude/drafts/.claude/launch.json
diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx
index e3ab6d5004..ac0a80bb55 100644
--- a/packages/shared/src/components/MainLayout.tsx
+++ b/packages/shared/src/components/MainLayout.tsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import PromotionalBanner from './PromotionalBanner';
+import { PostOnboardingActivation } from './post/PostOnboardingActivation';
import useSidebarRendered from '../hooks/useSidebarRendered';
import { useLogContext } from '../contexts/LogContext';
import SettingsContext from '../contexts/SettingsContext';
@@ -296,6 +297,7 @@ function MainLayoutComponent({
)}
>
{canGoBack && }
+
{customBanner}
{isBannerAvailable && }
diff --git a/packages/shared/src/components/post/PostOnboardingActivation.module.css b/packages/shared/src/components/post/PostOnboardingActivation.module.css
new file mode 100644
index 0000000000..31d03def83
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivation.module.css
@@ -0,0 +1,60 @@
+/* The banner is always dark regardless of the app theme, so its light-on-dark
+ * colours are declared here with explicit rgba values. Tailwind's opacity
+ * modifier on `white` (e.g. text-white/60) does NOT compile in this config —
+ * it silently falls back to the inherited (dark) theme colour and disappears
+ * on the near-black panel — so we can't rely on it. */
+
+/* Soft brand glow anchored to the right of the strip. Uses radial gradients
+ * with color-mix on the theme accent vars (mirrors .top-hero-aurora) so it
+ * renders reliably instead of relying on opacity-modified accent utilities. */
+.glow {
+ background:
+ radial-gradient(
+ 60% 170% at 50% 50%,
+ color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 64%) 0%,
+ transparent 62%
+ ),
+ radial-gradient(
+ 42% 150% at 15% 50%,
+ color-mix(in srgb, var(--theme-accent-onion-default), transparent 76%) 0%,
+ transparent 58%
+ ),
+ radial-gradient(
+ 42% 150% at 85% 50%,
+ color-mix(in srgb, var(--theme-accent-onion-default), transparent 76%) 0%,
+ transparent 58%
+ );
+}
+
+.border {
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.sheen {
+ background: linear-gradient(
+ to right,
+ transparent,
+ rgba(255, 255, 255, 0.22),
+ transparent
+ );
+}
+
+.description {
+ color: rgba(255, 255, 255, 0.62);
+}
+
+.ringTrack {
+ stroke: rgba(255, 255, 255, 0.16);
+}
+
+/* CTA is pinned to the white (dark-theme) primary look on the dark panel. */
+.cta,
+.cta:hover {
+ background-color: #fff !important;
+ color: #0b0d12 !important;
+ border-color: transparent !important;
+}
+
+.cta:hover {
+ background-color: rgba(255, 255, 255, 0.88) !important;
+}
diff --git a/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx b/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx
new file mode 100644
index 0000000000..4bc41ce085
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PostOnboardingActivation } from './PostOnboardingActivation';
+
+const mockPush = jest.fn();
+const mockLogEvent = jest.fn();
+const mockUseConditionalFeature = jest.fn();
+
+jest.mock('next/router', () => ({
+ useRouter: () => ({
+ asPath: '/posts/post-1?ref=share',
+ pathname: '/posts/[id]',
+ query: {},
+ push: mockPush,
+ }),
+}));
+
+jest.mock('../../contexts/AuthContext', () => ({
+ useAuthContext: () => ({ user: { id: 'new-user' } }),
+}));
+
+jest.mock('../../hooks/auth/useOnboardingActions', () => ({
+ useOnboardingActions: () => ({
+ isOnboardingActionsReady: true,
+ isOnboardingComplete: false,
+ }),
+}));
+
+jest.mock('../../contexts/LogContext', () => ({
+ useLogContext: () => ({ logEvent: mockLogEvent }),
+}));
+
+jest.mock('../../hooks/useConditionalFeature', () => ({
+ useConditionalFeature: (args: unknown) => mockUseConditionalFeature(args),
+}));
+
+describe('PostOnboardingActivation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseConditionalFeature.mockReturnValue({
+ value: true,
+ isLoading: false,
+ });
+ });
+
+ it('shows the feed setup prompt and routes to onboarding on click', async () => {
+ render();
+
+ expect(
+ await screen.findByText("Your feed isn't set up yet"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("You're one step away from discovering what's next."),
+ ).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Finish setup' }));
+
+ expect(mockPush).toHaveBeenCalledWith({
+ pathname: '/onboarding',
+ query: { after_auth: '/posts/post-1?ref=share' },
+ });
+ });
+
+ it('is a required step with no dismiss control', async () => {
+ render();
+
+ await screen.findByRole('complementary', {
+ name: 'Personalize your feed',
+ });
+
+ expect(
+ screen.queryByRole('button', { name: 'Dismiss feed personalization' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('stays hidden while the feature flag is off', () => {
+ mockUseConditionalFeature.mockReturnValue({
+ value: false,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByRole('complementary', { name: 'Personalize your feed' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/components/post/PostOnboardingActivation.tsx b/packages/shared/src/components/post/PostOnboardingActivation.tsx
new file mode 100644
index 0000000000..b10a4479c7
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx
@@ -0,0 +1,119 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useRef } from 'react';
+import { useRouter } from 'next/router';
+import { PostOnboardingActivationView } from './PostOnboardingActivationView';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions';
+import { useLogContext } from '../../contexts/LogContext';
+import { useConditionalFeature } from '../../hooks/useConditionalFeature';
+import { featurePostSignupActivation } from '../../lib/featureManagement';
+import { TargetType } from '../../lib/log';
+import { AFTER_AUTH_PARAM } from '../auth/common';
+
+// Force the bar on for local review without GrowthBook via `?=1`.
+const PREVIEW_QUERY = 'postOnboardingPreview';
+const isPreviewEnabled = (value?: string | string[]): boolean =>
+ value === '1' ||
+ value === 'true' ||
+ (Array.isArray(value) && (value.includes('1') || value.includes('true')));
+
+export const PostOnboardingActivation = (): ReactElement | null => {
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const { isOnboardingActionsReady, isOnboardingComplete } =
+ useOnboardingActions();
+ const hasLoggedImpression = useRef(false);
+ const wrapperRef = useRef(null);
+
+ // Never show on the onboarding flow itself — that's where the CTA leads.
+ const isOnboardingRoute = router.pathname?.startsWith('/onboarding');
+ const isPreviewMode = isPreviewEnabled(router.query?.[PREVIEW_QUERY]);
+
+ // Signed-in user who registered but hasn't set up their feed (no tag/content
+ // customization). This is the audience for the required activation step.
+ const isEligible =
+ !isOnboardingRoute &&
+ !!user?.id &&
+ isOnboardingActionsReady &&
+ !isOnboardingComplete;
+
+ const { value: isFeatureEnabled } = useConditionalFeature({
+ feature: featurePostSignupActivation,
+ shouldEvaluate: isEligible,
+ });
+
+ const shouldShow =
+ !isOnboardingRoute && (isPreviewMode || (isEligible && isFeatureEnabled));
+
+ useEffect(() => {
+ if (!shouldShow || hasLoggedImpression.current) {
+ return;
+ }
+
+ hasLoggedImpression.current = true;
+ logEvent({
+ event_name: 'impression',
+ target_type: TargetType.PostSignupActivation,
+ target_id: 'post signup activation',
+ });
+ }, [logEvent, shouldShow]);
+
+ // The bar is pinned above all fixed chrome. Reserve its height at the top of
+ // the app by feeding it into `--safe-area-top` (which shifts the rail, header
+ // and body content down) while preserving the real device notch inset.
+ useEffect(() => {
+ const el = wrapperRef.current;
+ if (!shouldShow || !el || typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+
+ const root = document.documentElement;
+ const apply = (): void => {
+ const height = Math.round(el.getBoundingClientRect().height);
+ root.style.setProperty(
+ '--safe-area-top',
+ `calc(env(safe-area-inset-top, 0px) + ${height}px)`,
+ );
+ };
+
+ apply();
+ const observer = new ResizeObserver(apply);
+ observer.observe(el);
+
+ return () => {
+ observer.disconnect();
+ root.style.removeProperty('--safe-area-top');
+ };
+ }, [shouldShow]);
+
+ if (!shouldShow) {
+ return null;
+ }
+
+ const onBuildFeed = (): void => {
+ logEvent({
+ event_name: 'click',
+ target_type: TargetType.PostSignupActivation,
+ target_id: 'build feed from post signup activation',
+ });
+
+ router.push({
+ pathname: '/onboarding',
+ query: { [AFTER_AUTH_PARAM]: router.asPath },
+ });
+ };
+
+ return (
+ // Pinned above everything. `top` is set inline (not the `top-0` class) so
+ // the global safe-area rule doesn't push the bar itself down; z sits above
+ // the notch-fill (`max`) so it stays visible.
+
+
+
+ );
+};
diff --git a/packages/shared/src/components/post/PostOnboardingActivationView.tsx b/packages/shared/src/components/post/PostOnboardingActivationView.tsx
new file mode 100644
index 0000000000..332cc49109
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivationView.tsx
@@ -0,0 +1,131 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './PostOnboardingActivation.module.css';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+} from '../typography/Typography';
+
+export interface PostOnboardingActivationViewProps {
+ title?: string;
+ description?: string;
+ ctaLabel?: string;
+ /** Completed steps of the onboarding, drives the progress ring. */
+ progress?: number;
+ /** Total steps of the onboarding. */
+ steps?: number;
+ onCtaClick?: () => void;
+ className?: string;
+}
+
+const RADIUS = 15;
+const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+
+export const PostOnboardingActivationView = ({
+ title = "Your feed isn't set up yet",
+ description = "You're one step away from discovering what's next.",
+ ctaLabel = 'Finish setup',
+ progress = 1,
+ steps = 2,
+ onCtaClick,
+ className,
+}: PostOnboardingActivationViewProps): ReactElement => {
+ const ratio = steps > 0 ? Math.min(Math.max(progress / steps, 0), 1) : 0;
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index e30adb28f5..278492bfe0 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -293,3 +293,12 @@ export const featureNotificationsRedesign = new Feature(
// `analytics.impressions` field. Control hides it entirely. Keep the default
// `false` — GrowthBook ramps it.
export const featureCardImpressions = new Feature('card_impressions', false);
+
+// Post-signup feed activation bar: a persistent, non-dismissible strip shown
+// above the header on every page for signed-in users who registered but have
+// not set up their feed yet (no tag/content customization). Control hides it
+// entirely. Keep the default `false` — GrowthBook ramps it.
+export const featurePostSignupActivation = new Feature(
+ 'post_signup_activation',
+ false,
+);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 4d37a4b92b..97dc8b6619 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -531,6 +531,7 @@ export enum TargetType {
SpotlightCommand = 'spotlight command',
MyFeedModal = 'my feed modal',
ArticleAnonymousCTA = 'article anonymous cta',
+ PostSignupActivation = 'post signup activation',
EnableNotifications = 'enable notifications',
OnboardingChecklist = 'onboarding checklist',
LoginButton = 'login button',
diff --git a/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx
new file mode 100644
index 0000000000..a4c6c3b2e1
--- /dev/null
+++ b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { PostOnboardingActivationView } from '@dailydotdev/shared/src/components/post/PostOnboardingActivationView';
+
+const meta: Meta = {
+ title: 'Components/Post/PostOnboardingActivation',
+ component: PostOnboardingActivationView,
+ args: {
+ title: "Your feed isn't set up yet",
+ description: "You're one step away from discovering what's next.",
+ ctaLabel: 'Finish setup',
+ progress: 1,
+ steps: 2,
+ },
+ argTypes: {
+ progress: { control: { type: 'number', min: 0, max: 5, step: 1 } },
+ steps: { control: { type: 'number', min: 1, max: 5, step: 1 } },
+ onCtaClick: { action: 'cta clicked' },
+ className: { table: { disable: true } },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * Interactive playground — tweak the copy, CTA label and progress ring via
+ * the Controls panel. The panel is intentionally always dark, so it looks the
+ * same under the light/dark theme toolbar toggle.
+ */
+export const Playground: Story = {};
+
+/** The exact configuration that ships on the post page. */
+export const Default: Story = {};
+
+/** Different progress fractions to sanity-check the ring + label. */
+export const ProgressVariations: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+};
+
+/** Copy-length stress test to check wrapping and alignment. */
+export const CopyVariations: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+/**
+ * Narrow viewport — the layout stacks, the CTA sits under the copy and the
+ * close control moves to the top-right corner.
+ */
+export const Mobile: Story = {
+ parameters: {
+ viewport: { defaultViewport: 'mobile1' },
+ },
+};