Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dbd903a
feat: add post-signup feed activation
tsahimatsliah Jul 20, 2026
c43ad5d
chore: preview post activation banner locally
tsahimatsliah Jul 20, 2026
c8a1c5b
chore: add hosted activation banner preview
tsahimatsliah Jul 20, 2026
23cb0cc
feat: redesign post onboarding activation
tsahimatsliah Jul 20, 2026
4e84374
fix: strengthen activation banner contrast
tsahimatsliah Jul 20, 2026
d0b28f6
feat: polish post activation announcement
tsahimatsliah Jul 20, 2026
6a8ed14
Merge remote-tracking branch 'origin/codex/post-signup-feed-activatio…
tsahimatsliah Jul 22, 2026
23ea3e1
feat: redesign post-signup activation banner as dark glossy strip
tsahimatsliah Jul 22, 2026
ae0de90
chore: shorten activation banner subtitle
tsahimatsliah Jul 22, 2026
9d34531
chore: reword activation CTA to "Continue setup"
tsahimatsliah Jul 22, 2026
b9084c6
chore: content-width activation CTA and clearer copy
tsahimatsliah Jul 22, 2026
2e9f67d
fix: make activation banner glow render on-platform
tsahimatsliah Jul 22, 2026
5bf14c7
feat: extract activation view + storybook, switch to white primary CTA
tsahimatsliah Jul 22, 2026
b40f778
feat: make activation banner a dominant, centered, required announcement
tsahimatsliah Jul 22, 2026
be80519
feat: horizontal activation bar with centered capped content
tsahimatsliah Jul 22, 2026
b3a5bfa
feat: mount activation bar above the header on every page
tsahimatsliah Jul 22, 2026
5eea051
fix: pin activation bar above all fixed chrome
tsahimatsliah Jul 22, 2026
4746973
chore: size activation text to content and center the group
tsahimatsliah Jul 22, 2026
d8c5189
fix: wrap activation copy on mobile
tsahimatsliah Jul 22, 2026
c48c78c
refactor: gate activation bar behind a flag and drop the localStorage…
tsahimatsliah Jul 22, 2026
3718e78
Merge remote-tracking branch 'origin/main' into claude/post-signup-fe…
tsahimatsliah Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ plans/*.md

# Claude Code runtime state (hooks/skills/settings remain tracked)
.claude/scheduled_tasks.lock
.claude/drafts/
.claude/drafts/.claude/launch.json
2 changes: 2 additions & 0 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -296,6 +297,7 @@ function MainLayoutComponent({
)}
>
{canGoBack && <GoBackHeaderMobile />}
<PostOnboardingActivation />
{customBanner}
{isBannerAvailable && <PromotionalBanner />}
<InAppNotificationElement />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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(<PostOnboardingActivation />);

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(<PostOnboardingActivation />);

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(<PostOnboardingActivation />);

expect(
screen.queryByRole('complementary', { name: 'Personalize your feed' }),
).not.toBeInTheDocument();
});
});
119 changes: 119 additions & 0 deletions packages/shared/src/components/post/PostOnboardingActivation.tsx
Original file line number Diff line number Diff line change
@@ -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 `?<query>=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<HTMLDivElement>(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.
<div
ref={wrapperRef}
className="fixed inset-x-0 z-[1001]"
style={{ top: 0 }}
>
<PostOnboardingActivationView onCtaClick={onBuildFeed} />
</div>
);
};
Loading
Loading