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..cef163f364
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivation.module.css
@@ -0,0 +1,84 @@
+/* The bar sits below the device notch and above every piece of fixed chrome,
+ * so its height has to be reserved at the top of the app. The copy is fixed,
+ * so the height is a constant per breakpoint (mobile stacks the CTA under the
+ * copy) and the whole reservation is plain CSS — no measuring. */
+.pinned {
+ top: env(safe-area-inset-top, 0px);
+ height: var(--post-signup-activation-height);
+}
+
+/* Declared on `body` (not `:root`) so it overrides the `:root` default for the
+ * body, `body::before` and every fixed descendant that reads it. */
+:global(body):has(.pinned) {
+ --post-signup-activation-height: 9.25rem;
+ --safe-area-top: calc(
+ env(safe-area-inset-top, 0px) + var(--post-signup-activation-height)
+ );
+}
+
+@media (min-width: 656px) {
+ :global(body):has(.pinned) {
+ --post-signup-activation-height: 5rem;
+ }
+}
+
+/* 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..3c6039bf93
--- /dev/null
+++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx
@@ -0,0 +1,76 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import { useRouter } from 'next/router';
+import classNames from 'classnames';
+import styles from './PostOnboardingActivation.module.css';
+import { PostOnboardingActivationView } from './PostOnboardingActivationView';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions';
+import { useLogContext } from '../../contexts/LogContext';
+import useLogEventOnce from '../../hooks/log/useLogEventOnce';
+import { useConditionalFeature } from '../../hooks/useConditionalFeature';
+import { featurePostSignupActivation } from '../../lib/featureManagement';
+import { LogEvent, TargetType } from '../../lib/log';
+import { AFTER_AUTH_PARAM } from '../auth/common';
+
+export const PostOnboardingActivation = (): ReactElement | null => {
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const { isOnboardingActionsReady, isOnboardingComplete } =
+ useOnboardingActions();
+
+ // Never show on the onboarding flow itself — that's where the CTA leads.
+ const isOnboardingRoute = router.pathname?.startsWith('/onboarding');
+
+ // 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 = isEligible && isFeatureEnabled;
+
+ useLogEventOnce(
+ () => ({
+ event_name: LogEvent.Impression,
+ target_type: TargetType.PostSignupActivation,
+ }),
+ { condition: shouldShow },
+ );
+
+ if (!shouldShow) {
+ return null;
+ }
+
+ const onBuildFeed = (): void => {
+ logEvent({
+ event_name: LogEvent.Click,
+ target_type: TargetType.PostSignupActivation,
+ });
+
+ router.push({
+ pathname: '/onboarding',
+ query: { [AFTER_AUTH_PARAM]: router.asPath },
+ });
+ };
+
+ return (
+ // Pinned above everything: `top` and the reserved height live in the module
+ // (not the `top-0` class) so the global safe-area rule doesn't push the bar
+ // itself down; z sits above the notch-fill (`1000`) 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 3c27c7f795..1be284e118 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -296,3 +296,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 3946fec173..6ead0526c3 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -535,6 +535,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..13540332d0
--- /dev/null
+++ b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx
@@ -0,0 +1,91 @@
+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 default configuration mounted app-wide by the activation container. */
+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 and the CTA sits under the copy. */
+export const Mobile: Story = {
+ parameters: {
+ viewport: { defaultViewport: 'mobile1' },
+ },
+};