diff --git a/packages/shared/src/components/icons/GooglePlay/filled.svg b/packages/shared/src/components/icons/GooglePlay/filled.svg
new file mode 100644
index 00000000000..8ebe3d60100
--- /dev/null
+++ b/packages/shared/src/components/icons/GooglePlay/filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/GooglePlay/index.tsx b/packages/shared/src/components/icons/GooglePlay/index.tsx
new file mode 100644
index 00000000000..00fa91cbbc6
--- /dev/null
+++ b/packages/shared/src/components/icons/GooglePlay/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import FilledIcon from './filled.svg';
+
+export const GooglePlayIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx
index f1939108c8a..e3109e2802d 100644
--- a/packages/shared/src/components/layout/HeaderButtons.tsx
+++ b/packages/shared/src/components/layout/HeaderButtons.tsx
@@ -10,6 +10,7 @@ import { useViewSize, ViewSize } from '../../hooks';
import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton';
import { QuestHeaderButton } from '../header/QuestHeaderButton';
import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry';
+import { GetAppButton } from '../../features/getApp/components/GetAppButton';
interface HeaderButtonsProps {
additionalButtons?: ReactNode;
@@ -31,6 +32,10 @@ export function HeaderButtons({
if (!isLoggedIn) {
return (
+ {/* Sits ahead of the Log in / Sign up pair. It keeps its label here
+ because there is room, but it stays Float (not Primary) so it never
+ competes with Sign up, which is the CTA that matters logged out. */}
+
{additionalButtons}
+
diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx
index 0d814c6c7c3..0cf03a33e34 100644
--- a/packages/shared/src/components/layout/MainLayoutHeader.tsx
+++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx
@@ -72,6 +72,12 @@ function MainLayoutHeader({
diff --git a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
new file mode 100644
index 00000000000..8fc63f8bd01
--- /dev/null
+++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import { QueryClient } from '@tanstack/react-query';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import { GetAppButton } from './GetAppButton';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import { useViewSize } from '../../../hooks';
+import { appStoreUrl, playStoreUrl } from '../../../lib/constants';
+
+jest.mock('../../../hooks/useConditionalFeature', () => ({
+ useConditionalFeature: jest.fn(),
+}));
+
+jest.mock('../../../hooks', () => ({
+ ...jest.requireActual('../../../hooks'),
+ useViewSize: jest.fn(),
+}));
+
+const mockUseConditionalFeature = useConditionalFeature as jest.Mock;
+const mockUseViewSize = useViewSize as jest.Mock;
+
+const renderComponent = (props = {}, auth = {}) =>
+ render(
+
+
+ ,
+ );
+
+const triggerName = /get the daily\.dev mobile app/i;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseViewSize.mockReturnValue(true);
+ mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false });
+});
+
+describe('GetAppButton', () => {
+ it('should render the trigger when the feature is enabled on desktop', () => {
+ renderComponent();
+
+ expect(
+ screen.getByRole('button', { name: triggerName }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render nothing when the feature is off', () => {
+ mockUseConditionalFeature.mockReturnValue({
+ value: false,
+ isLoading: false,
+ });
+ renderComponent();
+
+ expect(
+ screen.queryByRole('button', { name: triggerName }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should render nothing below laptop, where the app is already at hand', () => {
+ mockUseViewSize.mockReturnValue(false);
+ renderComponent();
+
+ expect(
+ screen.queryByRole('button', { name: triggerName }),
+ ).not.toBeInTheDocument();
+ });
+
+ // The Android wrapper has no isIOSNative()-style runtime bridge and is
+ // flagged through boot data instead. A tablet/desktop-mode viewport can
+ // satisfy the laptop breakpoint from inside the app, so the boot signal must
+ // veto the viewport gate.
+ it('should render nothing inside the Android app even at laptop width', () => {
+ renderComponent({}, { isAndroidApp: true });
+
+ expect(
+ screen.queryByRole('button', { name: triggerName }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not evaluate the flag when the caller already resolved it', () => {
+ renderComponent({ isFeatureEnabled: false });
+
+ expect(mockUseConditionalFeature).toHaveBeenCalledWith(
+ expect.objectContaining({ shouldEvaluate: false }),
+ );
+ expect(
+ screen.queryByRole('button', { name: triggerName }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should show the QR code and both store links once opened', async () => {
+ renderComponent();
+
+ await userEvent.click(screen.getByRole('button', { name: triggerName }));
+
+ expect(
+ await screen.findByRole('img', { name: /qr code/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'App Store' })).toHaveAttribute(
+ 'href',
+ appStoreUrl,
+ );
+ expect(screen.getByRole('link', { name: 'Google Play' })).toHaveAttribute(
+ 'href',
+ playStoreUrl,
+ );
+ });
+
+ it('should render the label instead of the tooltip when asked', () => {
+ renderComponent({ showLabel: true });
+
+ expect(screen.getByText('Get the app')).toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx
new file mode 100644
index 00000000000..a36ad04a845
--- /dev/null
+++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx
@@ -0,0 +1,212 @@
+import type { ComponentType, ReactElement } from 'react';
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { PopoverContent } from '../../../components/popover/Popover';
+import { Tooltip } from '../../../components/tooltip/Tooltip';
+import { AppleIcon, PhoneIcon } from '../../../components/icons';
+import { GooglePlayIcon } from '../../../components/icons/GooglePlay';
+import type { IconProps } from '../../../components/Icon';
+import { IconSize } from '../../../components/Icon';
+import { useViewSize, ViewSize } from '../../../hooks';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import usePersistentContext from '../../../hooks/usePersistentContext';
+import { featureHeaderGetApp } from '../../../lib/featureManagement';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent, TargetType } from '../../../lib/log';
+import { isIOSNative } from '../../../lib/func';
+import { appStoreUrl, playStoreUrl } from '../../../lib/constants';
+import { GetAppQrCode } from './GetAppQrCode';
+
+interface AppStore {
+ id: string;
+ label: string;
+ href: string;
+ Icon: ComponentType;
+ // Optically balanced, not equal: the Apple glyph sits inside its viewBox at
+ // roughly 67% width, while the Play mark runs nearly edge to edge. Rendering
+ // both at one size makes Apple look shrunken, so Apple gets the larger box.
+ iconSize: IconSize;
+}
+
+const stores: AppStore[] = [
+ {
+ id: 'ios',
+ label: 'App Store',
+ href: appStoreUrl,
+ Icon: AppleIcon,
+ iconSize: IconSize.XSmall,
+ },
+ {
+ id: 'android',
+ label: 'Google Play',
+ href: playStoreUrl,
+ Icon: GooglePlayIcon,
+ iconSize: IconSize.Size16,
+ },
+];
+
+// One-time attention dot. The button is a permanent affordance, not a campaign,
+// so it nudges once and then goes quiet - a banner that keeps reappearing is
+// what we're deliberately not building here.
+const GET_APP_SEEN_KEY = 'getAppHeaderSeen';
+
+export interface GetAppButtonProps {
+ // The logged-out header has room for the full label; the logged-in action
+ // rail (opportunity, quests, giveback, bell, avatar) does not, so it gets the
+ // icon-only trigger with the label in a tooltip.
+ showLabel?: boolean;
+ className?: string;
+ // Escape hatch for surfaces that already resolved `featureHeaderGetApp` (and
+ // for Storybook, which has no GrowthBook instance). Left undefined, the
+ // button evaluates the flag itself.
+ isFeatureEnabled?: boolean;
+}
+
+export function GetAppButton({
+ showLabel = false,
+ className,
+ isFeatureEnabled: isFeatureEnabledProp,
+}: GetAppButtonProps): ReactElement | null {
+ const [isOpen, setIsOpen] = useState(false);
+ const { logEvent } = useLogContext();
+ const { isAndroidApp } = useAuthContext();
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ // The point of this entry point is telling *desktop* visitors that a mobile
+ // app exists. Below laptop we're either on mobile web or inside the native
+ // wrapper - which renders this same webapp shell - and neither should be
+ // told to go get an app they're already holding. The wrappers need their own
+ // signals on top of the viewport gate because a tablet/desktop-mode viewport
+ // can satisfy the laptop breakpoint from inside the app: iOS exposes a
+ // WebKit bridge at runtime (isIOSNative), Android has no such bridge and is
+ // flagged through boot data instead.
+ const shouldRender = isLaptop && !isIOSNative() && !isAndroidApp;
+ const { value: isFlagEnabled } = useConditionalFeature({
+ feature: featureHeaderGetApp,
+ shouldEvaluate: shouldRender && isFeatureEnabledProp === undefined,
+ });
+ const isFeatureEnabled = isFeatureEnabledProp ?? isFlagEnabled;
+ const [hasSeen, setHasSeen, isSeenFetched] = usePersistentContext(
+ GET_APP_SEEN_KEY,
+ false,
+ );
+
+ const onOpenChange = useCallback(
+ (open: boolean) => {
+ setIsOpen(open);
+
+ if (!open) {
+ return;
+ }
+
+ setHasSeen(true);
+ logEvent({
+ event_name: LogEvent.Click,
+ target_type: TargetType.GetAppButton,
+ });
+ },
+ [logEvent, setHasSeen],
+ );
+
+ const onStoreClick = useCallback(
+ (store: AppStore) => {
+ logEvent({
+ event_name: LogEvent.DownloadApp,
+ target_type: TargetType.GetAppButton,
+ target_id: store.id,
+ });
+ },
+ [logEvent],
+ );
+
+ if (!shouldRender || !isFeatureEnabled) {
+ return null;
+ }
+
+ const showDot = !showLabel && isSeenFetched && !hasSeen;
+
+ return (
+
+ {/* The dot is a sibling of the Button, not a child: Button derives its
+ icon-only geometry from `!children`, so nesting the dot inside would
+ switch it to the labelled layout (`pl-2 pr-4 gap-1`) and push the
+ glyph 4px off centre. */}
+
+
+
+ }
+ aria-haspopup="dialog"
+ aria-expanded={isOpen}
+ aria-label="Get the daily.dev mobile app"
+ >
+ {showLabel ? 'Get the app' : null}
+
+
+
+ {showDot && (
+
+ )}
+
+
+
+ {/* Level H is a denser matrix than the plain code was, so it gets a
+ bigger plate to keep the modules comfortably scannable. */}
+
+ {/* The global reset pins `flex-shrink: 0`, so the copy needs both
+ `flex-1` and `min-w-0` or it blows past the panel instead of
+ wrapping. */}
+
+
+ daily.dev on your phone
+
+
+ Scan the code with your camera. Your feed, bookmarks and streak
+ come with you.
+
+
+
+ );
+}
+
+export default GetAppButton;
diff --git a/packages/shared/src/features/getApp/components/GetAppQrCode.tsx b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx
new file mode 100644
index 00000000000..a0a01e8049f
--- /dev/null
+++ b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx
@@ -0,0 +1,53 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { DailyIcon } from '../../../components/icons';
+// A desktop visitor can't install a phone app from the machine they're on, so
+// the header entry point hands them a scannable code instead of a dead-end
+// store link. The asset is a pre-rendered SVG (see the regeneration note
+// inside it) rather than a runtime QR library: the target URL never changes,
+// so one static image beats shipping a generator in the header bundle. It
+// lives under an `icons/` directory because the extension's rspack config
+// only runs SVGR on paths matching `icons/`.
+//
+// Error correction is level H (recovers ~30% of the matrix) specifically so
+// the centre can be knocked out for the logo badge below. Dropping back to
+// level M would make the branded version unscannable.
+import QrSvg from '../icons/getAppQr.svg';
+
+interface GetAppQrCodeProps {
+ className?: string;
+}
+
+export function GetAppQrCode({ className }: GetAppQrCodeProps): ReactElement {
+ return (
+ // Scanners want dark modules on a light plate, so this stays white in both
+ // themes instead of inheriting the popover surface.
+
+
+ {/* Knockout badge for the brand mark. At a quarter of the code's width it
+ obscures ~6% of the modules, well inside what level H recovers. The
+ white ring stops the dark badge from merging into whichever modules
+ happen to sit against it. */}
+
+ {/* DailyIcon draws in `currentColor`, so the badge's `text-white` makes
+ the mark white in both themes. The svg/LogoIcon variant hardcodes
+ `--theme-text-primary` and would flip with the theme instead. The
+ important modifiers override the Icon wrapper's fixed size so the
+ mark scales with the badge. */}
+
+
+
+ );
+}
+
+export default GetAppQrCode;
diff --git a/packages/shared/src/features/getApp/icons/getAppQr.svg b/packages/shared/src/features/getApp/icons/getAppQr.svg
new file mode 100644
index 00000000000..cc13cf6389d
--- /dev/null
+++ b/packages/shared/src/features/getApp/icons/getAppQr.svg
@@ -0,0 +1,9 @@
+
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 0d244dab67a..8973137660b 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -323,3 +323,9 @@ export const featurePostSignupActivation = new Feature(
'post_signup_activation',
false,
);
+
+// Header entry point promoting the daily.dev mobile app to desktop webapp and
+// extension visitors: a phone button in the header that opens a QR-code
+// popover. Control hides it entirely. Keep the default `false` - GrowthBook
+// ramps it.
+export const featureHeaderGetApp = new Feature('header_get_app', false);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 6ead0526c33..6018410cc72 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -540,6 +540,7 @@ export enum TargetType {
OnboardingChecklist = 'onboarding checklist',
LoginButton = 'login button',
SignupButton = 'signup button',
+ GetAppButton = 'get app button',
SquadJoinButton = 'squad join button',
SearchRecommendation = 'search rec',
SearchHistory = 'search history',
diff --git a/packages/storybook/stories/components/GetAppButton.stories.tsx b/packages/storybook/stories/components/GetAppButton.stories.tsx
new file mode 100644
index 00000000000..54c6fdc46af
--- /dev/null
+++ b/packages/storybook/stories/components/GetAppButton.stories.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext';
+import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import { GetAppButton } from '@dailydotdev/shared/src/features/getApp/components/GetAppButton';
+import { GetAppQrCode } from '@dailydotdev/shared/src/features/getApp/components/GetAppQrCode';
+import {
+ Button,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { BellIcon } from '@dailydotdev/shared/src/components/icons';
+
+const queryClient = new QueryClient();
+
+const meta: Meta = {
+ title: 'Components/GetAppButton',
+ component: GetAppButton,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story) => (
+
+ {/* The render gate reads isAndroidApp from AuthContext (the Android
+ wrapper has no runtime bridge), so the story must provide the
+ context the real app always has. */}
+
+
+
+
+
+
+ ),
+ ],
+ // Storybook has no GrowthBook instance, so the flag is forced on here.
+ args: { isFeatureEnabled: true },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Mirrors the real header shell so the trigger can be judged in context rather
+// than floating on an empty canvas.
+const HeaderShell = ({
+ children,
+}: {
+ children: React.ReactNode;
+}): React.ReactElement => (
+
+ daily.dev
+
{children}
+
+);
+
+export const InHeaderLoggedIn: Story = {
+ render: (args) => (
+
+
+ }
+ aria-label="Notifications"
+ />
+
+
+ ),
+};
+
+// Stands in for the real LoginButton, which renders a Secondary "Log in" and a
+// Primary "Sign up" side by side inside a `gap-4` span.
+const AuthButtons = (): React.ReactElement => (
+
+
+
+
+);
+
+export const InHeaderLoggedOut: Story = {
+ args: { showLabel: true },
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+// Same slot, icon-only. Worth comparing: with Log in AND Sign up already in the
+// row, a third labelled button competes with Sign up, which is the CTA that
+// actually matters to a logged-out visitor.
+export const InHeaderLoggedOutCompact: Story = {
+ args: { showLabel: false },
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+// The panel is the part worth reviewing closely; this pins it open next to the
+// bare QR so contrast can be checked against both themes.
+export const Panel: Story = {
+ render: (args) => (
+