From 2ccecfea09413b36e0b046a2da3910897a674022 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 15:39:58 +0300 Subject: [PATCH 1/8] feat: add "Get the app" header entry point with QR popover Desktop and extension visitors have no visible path to the mobile app - the only pointer lives buried in the profile menu. This adds a phone button to the header that opens a popover with a scannable QR code plus App Store and Google Play links. A desktop visitor cannot install a phone app from the machine they are on, so the QR is the primary affordance and the store links are the fallback. The code is a pre-rendered matrix rather than a runtime library, generated at error-correction level H so the centre can carry the daily.dev mark without becoming unscannable. Gated behind the header_get_app flag, default false. Rendered only at laptop width and up: the native iOS/Android wrappers run this same webapp shell and should not be told to install an app they are already running. Co-Authored-By: Claude Opus 5 --- .../src/components/layout/HeaderButtons.tsx | 6 + .../getApp/components/GetAppButton.spec.tsx | 102 +++++++++ .../getApp/components/GetAppButton.tsx | 196 ++++++++++++++++++ .../getApp/components/GetAppQrCode.tsx | 66 ++++++ packages/shared/src/lib/featureManagement.ts | 6 + packages/shared/src/lib/log.ts | 1 + .../components/GetAppButton.stories.tsx | 110 ++++++++++ 7 files changed, 487 insertions(+) create mode 100644 packages/shared/src/features/getApp/components/GetAppButton.spec.tsx create mode 100644 packages/shared/src/features/getApp/components/GetAppButton.tsx create mode 100644 packages/shared/src/features/getApp/components/GetAppQrCode.tsx create mode 100644 packages/storybook/stories/components/GetAppButton.stories.tsx 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/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx new file mode 100644 index 00000000000..1e17ba85c9a --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx @@ -0,0 +1,102 @@ +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 = {}) => + 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(); + }); + + 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..616cfa4e877 --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -0,0 +1,196 @@ +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'; +// AndroidIcon is not re-exported by the icons barrel, so import it directly. +import { AndroidIcon } from '../../../components/icons/Android'; +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 { 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; +} + +const stores: AppStore[] = [ + { id: 'ios', label: 'App Store', href: appStoreUrl, Icon: AppleIcon }, + { + id: 'android', + label: 'Google Play', + href: playStoreUrl, + Icon: AndroidIcon, + }, +]; + +// 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 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. + const shouldRender = isLaptop && !isIOSNative(); + 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. */} + + + + + + + {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. +

+
+
+
+ {stores.map((store) => ( + + ))} +
+
+
+ ); +} + +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..49887f98134 --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { DailyIcon } from '../../../components/icons'; +import { appsUrl } from '../../../lib/constants'; + +// 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. Attribution rides along in the URL because the QR is the only +// hop we get - once the phone opens it, the desktop session is out of the loop. +export const getAppQrUrl = `${appsUrl}?utm_source=header_qr`; + +// Pre-rendered matrix for `getAppQrUrl` (45x45 units = 37 modules plus the +// 4-module quiet zone). The target never changes at runtime, so we ship the +// matrix rather than pull a QR library into the header bundle for one code. +// Editing `getAppQrUrl` without regenerating this silently ships a code that +// still points at the old URL. Regenerate with: +// npx qrcode -t svg -e H -- "" +// +// Error correction is level H (recovers ~30% of the matrix) specifically so the +// centre can be knocked out for the logo. Dropping back to level M would make +// the branded version unscannable. +const QR_MATRIX_PATH = + 'M4 4.5h7m2 0h2m1 0h2m3 0h2m1 0h2m1 0h1m1 0h1m2 0h1m1 0h7M4 5.5h1m5 0h1m3 0h1m1 0h3m3 0h2m1 0h5m2 0h1m1 0h1m5 0h1M4 6.5h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m1 0h1m3 0h1m1 0h3m2 0h1m1 0h3m1 0h1M4 7.5h1m1 0h3m1 0h1m8 0h1m6 0h1m1 0h3m3 0h1m1 0h3m1 0h1M4 8.5h1m1 0h3m1 0h1m1 0h1m1 0h1m5 0h2m1 0h7m1 0h1m2 0h1m1 0h3m1 0h1M4 9.5h1m5 0h1m2 0h4m1 0h3m3 0h4m2 0h1m3 0h1m5 0h1M4 10.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M12 11.5h1m4 0h3m1 0h1m4 0h1m1 0h1m2 0h2M6 12.5h2m2 0h3m3 0h5m1 0h3m2 0h1m2 0h2m1 0h2m1 0h1M5 13.5h2m1 0h2m2 0h4m1 0h1m1 0h3m1 0h2m3 0h5m2 0h1m2 0h2M5 14.5h2m1 0h1m1 0h1m2 0h2m1 0h3m2 0h1m2 0h3m1 0h2m3 0h1m1 0h3m1 0h1M5 15.5h2m1 0h2m1 0h1m2 0h1m2 0h3m2 0h1m1 0h1m1 0h2m1 0h4m2 0h1m1 0h4M4 16.5h1m2 0h2m1 0h1m4 0h3m1 0h1m1 0h1m5 0h5m1 0h2m1 0h3m1 0h1M4 17.5h1m4 0h1m3 0h2m1 0h5m3 0h2m1 0h2m1 0h3m1 0h3m1 0h3M5 18.5h1m2 0h1m1 0h3m1 0h2m1 0h3m2 0h3m1 0h1m1 0h2m3 0h1m1 0h1m2 0h2M4 19.5h6m2 0h4m1 0h1m2 0h2m2 0h1m2 0h2m1 0h1m3 0h2m3 0h2M5 20.5h1m4 0h3m1 0h3m5 0h1m1 0h2m1 0h3m7 0h4M5 21.5h2m2 0h1m3 0h1m1 0h3m1 0h1m2 0h11m1 0h1m2 0h1m2 0h1M4 22.5h1m4 0h2m3 0h2m1 0h2m4 0h1m2 0h1m1 0h1m1 0h2m1 0h1m2 0h2m1 0h2M6 23.5h1m1 0h2m3 0h2m1 0h2m1 0h1m2 0h2m3 0h2m2 0h2m2 0h1m1 0h1m2 0h1M4 24.5h1m2 0h1m2 0h1m1 0h3m1 0h1m2 0h1m1 0h1m8 0h1m1 0h2m2 0h2m2 0h1M4 25.5h1m1 0h4m3 0h4m2 0h4m1 0h2m1 0h1m2 0h1m2 0h3m2 0h2M8 26.5h3m3 0h1m6 0h1m1 0h1m2 0h2m2 0h1m5 0h4M5 27.5h1m2 0h2m3 0h1m2 0h1m1 0h3m1 0h1m2 0h3m1 0h4m4 0h3M6 28.5h2m1 0h2m1 0h2m1 0h4m1 0h1m1 0h1m1 0h2m1 0h4m1 0h3m1 0h1m1 0h3M5 29.5h2m2 0h1m6 0h3m1 0h2m1 0h2m2 0h2m2 0h6m1 0h1m1 0h1M5 30.5h7m1 0h1m4 0h10m1 0h2m7 0h2M4 31.5h1m1 0h1m2 0h1m2 0h1m1 0h4m3 0h1m3 0h1m1 0h1m3 0h3m1 0h1m4 0h1M7 32.5h1m1 0h6m2 0h1m1 0h1m3 0h2m1 0h1m2 0h1m2 0h5m1 0h2M12 33.5h2m1 0h1m2 0h1m1 0h2m2 0h2m1 0h3m1 0h2m3 0h3m1 0h1M4 34.5h7m1 0h3m4 0h2m3 0h3m5 0h1m1 0h1m1 0h2m1 0h2M4 35.5h1m5 0h1m3 0h2m1 0h3m1 0h4m5 0h3m3 0h2M4 36.5h1m1 0h3m1 0h1m2 0h1m3 0h5m1 0h4m3 0h1m1 0h6M4 37.5h1m1 0h3m1 0h1m1 0h3m1 0h1m2 0h2m2 0h2m3 0h1m1 0h1m1 0h1m1 0h5M4 38.5h1m1 0h3m1 0h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h2m1 0h3m1 0h4m1 0h4m1 0h1M4 39.5h1m5 0h1m4 0h1m2 0h1m5 0h1m2 0h1m1 0h1m1 0h1m1 0h1m4 0h1M4 40.5h7m4 0h4m4 0h1m4 0h3m2 0h3m1 0h4'; + +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. `text-black` also feeds + // the logo, which draws in `currentColor`. +
+ + + + {/* 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/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a7d61e52f0f..246f9b979f8 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -313,3 +313,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..1f1687175bb --- /dev/null +++ b/packages/storybook/stories/components/GetAppButton.stories.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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) => ( + +
+ +
+
+ ), + ], + // 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) => ( + + + + + +); + +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) => ( +
+

+ Click the phone button to open the panel. +

+ + + + +
+ ), +}; From e35b4fae4ad37e66caa5a66c2f53f99aa6f2d691 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 08:53:21 +0300 Subject: [PATCH 2/8] TEMP: force header_get_app default true for preview review REVERT BEFORE MERGE. GrowthBook has no `header_get_app` feature yet, and the DevTools extension cannot attach on preview deploys because they build as production. Flipping the committed default is the only way to see the entry point on the preview. Merging this as-is ships the experiment to 100% of users with no rollback short of a deploy. Restore `new Feature('header_get_app', false)`. Co-Authored-By: Claude Opus 5 --- packages/shared/src/lib/featureManagement.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 246f9b979f8..59411be5ff3 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -318,4 +318,11 @@ export const featurePostSignupActivation = new Feature( // 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); +// +// !!! TEMP - REVERT BEFORE MERGE !!! +// Forced to `true` so the entry point renders on the preview deploy, where +// GrowthBook has no `header_get_app` feature yet and DevTools cannot attach +// (preview builds run as production). Merging this line as-is ships the +// experiment to 100% of users with no rollback short of a deploy. +// Restore: new Feature('header_get_app', false) +export const featureHeaderGetApp = new Feature('header_get_app', true); From b5ab18802250de3bee5e7f8d8199ab1a2cd3b120 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 10:16:11 +0300 Subject: [PATCH 3/8] style(get-app): make the store links primary buttons The App Store / Google Play links were Secondary outlines, which read as tertiary next to the QR. They are the fallback path for anyone who cannot scan, so they get the solid Primary fill and the Medium size instead. Both icons draw in currentColor, so they invert with the fill and stay legible in either theme. Co-Authored-By: Claude Opus 5 --- .../shared/src/features/getApp/components/GetAppButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx index 616cfa4e877..db719741390 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -178,8 +178,8 @@ export function GetAppButton({ href={store.href} target="_blank" rel="noopener" - variant={ButtonVariant.Secondary} - size={ButtonSize.Small} + variant={ButtonVariant.Primary} + size={ButtonSize.Medium} className="flex-1" icon={} onClick={() => onStoreClick(store)} From a06e215371e3e946399637a80e73e1ed2f8b84e6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 10:39:29 +0300 Subject: [PATCH 4/8] fix(get-app): use the Google Play mark and balance the store icons The Android robot is the OS logo, not the storefront, so it was the wrong mark next to "Google Play". Adds the official Play glyph as a local icon drawn in currentColor, matching how AndroidIcon was built. The two icons also read at different sizes because their artwork fills the viewBox differently: the Apple glyph spans ~67% of its box while the Play mark runs nearly edge to edge. At one shared size the Apple logo had 56% less ink area. Sizing Apple's box up to 20px against Play's 16px lands them within 4% of each other (ink 13.2x16.7 vs 14.4x16.0). Co-Authored-By: Claude Opus 5 --- .../components/icons/GooglePlay/filled.svg | 3 +++ .../src/components/icons/GooglePlay/index.tsx | 9 +++++++++ .../getApp/components/GetAppButton.tsx | 20 ++++++++++++++----- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/components/icons/GooglePlay/filled.svg create mode 100644 packages/shared/src/components/icons/GooglePlay/index.tsx 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/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx index db719741390..c3a69b2e30d 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -10,8 +10,7 @@ import { import { PopoverContent } from '../../../components/popover/Popover'; import { Tooltip } from '../../../components/tooltip/Tooltip'; import { AppleIcon, PhoneIcon } from '../../../components/icons'; -// AndroidIcon is not re-exported by the icons barrel, so import it directly. -import { AndroidIcon } from '../../../components/icons/Android'; +import { GooglePlayIcon } from '../../../components/icons/GooglePlay'; import type { IconProps } from '../../../components/Icon'; import { IconSize } from '../../../components/Icon'; import { useViewSize, ViewSize } from '../../../hooks'; @@ -29,15 +28,26 @@ interface AppStore { 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 }, + { + id: 'ios', + label: 'App Store', + href: appStoreUrl, + Icon: AppleIcon, + iconSize: IconSize.XSmall, + }, { id: 'android', label: 'Google Play', href: playStoreUrl, - Icon: AndroidIcon, + Icon: GooglePlayIcon, + iconSize: IconSize.Size16, }, ]; @@ -181,7 +191,7 @@ export function GetAppButton({ variant={ButtonVariant.Primary} size={ButtonSize.Medium} className="flex-1" - icon={} + icon={} onClick={() => onStoreClick(store)} > {store.label} From 25f363fb496734417569f808b61f612e8182adf1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 12:09:32 +0300 Subject: [PATCH 5/8] fix(header): let the search absorb a crowded action rail Every header child is flex-shrink:0 via the global reset, so once the logged-in action rail outgrows its budget the header overflows instead of compressing: at 1020px the rail already overshoots the right edge by ~27px today and clips the logo on the left, and the new Get-the-app entry adds another 52px. The search is the one element that can give up width, so it now takes the remaining space (flex-1 + min-w-0) and its trigger caps at 26.25rem rather than being fixed there. Rendered width is unchanged at 420px wherever there is room - it only shrinks when the header would otherwise overflow. The laptop/laptopL max-widths removed from the trigger were dead: the fixed width never reached them. Measured against the real header with the rail reconstructed at true sizes: overshoot goes to 0 at 1020/1100/1280/1440 for profile clusters of 180/270/320px, and the logo is no longer clipped. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/layout/MainLayoutHeader.tsx | 6 ++++++ .../src/components/spotlight/SpotlightTrigger.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) 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({
From 92324b6dd08dce7169bb690ad79b4bd3c6c24804 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 15:35:00 +0300 Subject: [PATCH 6/8] Revert "TEMP: force header_get_app default true for preview review" This reverts commit e35b4fae4ad37e66caa5a66c2f53f99aa6f2d691. --- packages/shared/src/lib/featureManagement.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 6af3d483082..8973137660b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -328,11 +328,4 @@ export const featurePostSignupActivation = new Feature( // 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. -// -// !!! TEMP - REVERT BEFORE MERGE !!! -// Forced to `true` so the entry point renders on the preview deploy, where -// GrowthBook has no `header_get_app` feature yet and DevTools cannot attach -// (preview builds run as production). Merging this line as-is ships the -// experiment to 100% of users with no rollback short of a deploy. -// Restore: new Feature('header_get_app', false) -export const featureHeaderGetApp = new Feature('header_get_app', true); +export const featureHeaderGetApp = new Feature('header_get_app', false); From c6e6510ba43dc8a65cdb4159e9c462bdaa40be54 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 14:22:42 +0300 Subject: [PATCH 7/8] fix(get-app): exclude the Android app via its boot signal The render gate only vetoed the iOS wrapper (isIOSNative). Android native has no runtime bridge and is flagged through boot data (isAndroidApp on AuthContext) instead, so an Android tablet or desktop-mode viewport that satisfies the laptop breakpoint would be shown the install-app CTA from inside the app itself. The gate now also reads isAndroidApp from the auth context, with a regression test pinning the Android-app-at-laptop-width case. Co-Authored-By: Claude Fable 5 --- .../getApp/components/GetAppButton.spec.tsx | 16 ++++++++++++++-- .../features/getApp/components/GetAppButton.tsx | 10 ++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx index 1e17ba85c9a..8fc63f8bd01 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx @@ -20,9 +20,9 @@ jest.mock('../../../hooks', () => ({ const mockUseConditionalFeature = useConditionalFeature as jest.Mock; const mockUseViewSize = useViewSize as jest.Mock; -const renderComponent = (props = {}) => +const renderComponent = (props = {}, auth = {}) => render( - + , ); @@ -65,6 +65,18 @@ describe('GetAppButton', () => { ).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 }); diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx index c3a69b2e30d..a36ad04a845 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -17,6 +17,7 @@ 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'; @@ -75,12 +76,17 @@ export function GetAppButton({ }: 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. - const shouldRender = isLaptop && !isIOSNative(); + // 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, From 662aaea02964f6dc7f2d9d480ce6d77c3e47bc5c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 15:04:24 +0300 Subject: [PATCH 8/8] refactor(get-app): move the QR matrix into an SVG asset The QR was a 2KB path-data string constant baked into the component - hard to review, nonstandard, and pointlessly living in the TSX when the repo already has an SVGR pipeline for exactly this. It is now a plain .svg asset imported like every other icon. The file lives under features/getApp/icons/ because the extension's rspack config only runs SVGR on paths matching `icons/`. The generator's white background rect is dropped inside the asset since every SVGR config rewrites #ffffff to currentcolor, and the regeneration note rides inside the svg root - double hyphens are illegal in XML comments, which Vite surfaces as an opaque startup crash rather than a parse error. Also provides AuthContext in the GetAppButton stories: the Android-wrapper gate reads isAndroidApp from auth context, which Storybook was not providing. Co-Authored-By: Claude Fable 5 --- .../getApp/components/GetAppQrCode.tsx | 37 ++++++------------- .../src/features/getApp/icons/getAppQr.svg | 9 +++++ .../components/GetAppButton.stories.tsx | 15 ++++++-- 3 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 packages/shared/src/features/getApp/icons/getAppQr.svg diff --git a/packages/shared/src/features/getApp/components/GetAppQrCode.tsx b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx index 49887f98134..a0a01e8049f 100644 --- a/packages/shared/src/features/getApp/components/GetAppQrCode.tsx +++ b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx @@ -2,26 +2,18 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import { DailyIcon } from '../../../components/icons'; -import { appsUrl } from '../../../lib/constants'; - // 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. Attribution rides along in the URL because the QR is the only -// hop we get - once the phone opens it, the desktop session is out of the loop. -export const getAppQrUrl = `${appsUrl}?utm_source=header_qr`; - -// Pre-rendered matrix for `getAppQrUrl` (45x45 units = 37 modules plus the -// 4-module quiet zone). The target never changes at runtime, so we ship the -// matrix rather than pull a QR library into the header bundle for one code. -// Editing `getAppQrUrl` without regenerating this silently ships a code that -// still points at the old URL. Regenerate with: -// npx qrcode -t svg -e H -- "" +// 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. Dropping back to level M would make -// the branded version unscannable. -const QR_MATRIX_PATH = - 'M4 4.5h7m2 0h2m1 0h2m3 0h2m1 0h2m1 0h1m1 0h1m2 0h1m1 0h7M4 5.5h1m5 0h1m3 0h1m1 0h3m3 0h2m1 0h5m2 0h1m1 0h1m5 0h1M4 6.5h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m1 0h1m3 0h1m1 0h3m2 0h1m1 0h3m1 0h1M4 7.5h1m1 0h3m1 0h1m8 0h1m6 0h1m1 0h3m3 0h1m1 0h3m1 0h1M4 8.5h1m1 0h3m1 0h1m1 0h1m1 0h1m5 0h2m1 0h7m1 0h1m2 0h1m1 0h3m1 0h1M4 9.5h1m5 0h1m2 0h4m1 0h3m3 0h4m2 0h1m3 0h1m5 0h1M4 10.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M12 11.5h1m4 0h3m1 0h1m4 0h1m1 0h1m2 0h2M6 12.5h2m2 0h3m3 0h5m1 0h3m2 0h1m2 0h2m1 0h2m1 0h1M5 13.5h2m1 0h2m2 0h4m1 0h1m1 0h3m1 0h2m3 0h5m2 0h1m2 0h2M5 14.5h2m1 0h1m1 0h1m2 0h2m1 0h3m2 0h1m2 0h3m1 0h2m3 0h1m1 0h3m1 0h1M5 15.5h2m1 0h2m1 0h1m2 0h1m2 0h3m2 0h1m1 0h1m1 0h2m1 0h4m2 0h1m1 0h4M4 16.5h1m2 0h2m1 0h1m4 0h3m1 0h1m1 0h1m5 0h5m1 0h2m1 0h3m1 0h1M4 17.5h1m4 0h1m3 0h2m1 0h5m3 0h2m1 0h2m1 0h3m1 0h3m1 0h3M5 18.5h1m2 0h1m1 0h3m1 0h2m1 0h3m2 0h3m1 0h1m1 0h2m3 0h1m1 0h1m2 0h2M4 19.5h6m2 0h4m1 0h1m2 0h2m2 0h1m2 0h2m1 0h1m3 0h2m3 0h2M5 20.5h1m4 0h3m1 0h3m5 0h1m1 0h2m1 0h3m7 0h4M5 21.5h2m2 0h1m3 0h1m1 0h3m1 0h1m2 0h11m1 0h1m2 0h1m2 0h1M4 22.5h1m4 0h2m3 0h2m1 0h2m4 0h1m2 0h1m1 0h1m1 0h2m1 0h1m2 0h2m1 0h2M6 23.5h1m1 0h2m3 0h2m1 0h2m1 0h1m2 0h2m3 0h2m2 0h2m2 0h1m1 0h1m2 0h1M4 24.5h1m2 0h1m2 0h1m1 0h3m1 0h1m2 0h1m1 0h1m8 0h1m1 0h2m2 0h2m2 0h1M4 25.5h1m1 0h4m3 0h4m2 0h4m1 0h2m1 0h1m2 0h1m2 0h3m2 0h2M8 26.5h3m3 0h1m6 0h1m1 0h1m2 0h2m2 0h1m5 0h4M5 27.5h1m2 0h2m3 0h1m2 0h1m1 0h3m1 0h1m2 0h3m1 0h4m4 0h3M6 28.5h2m1 0h2m1 0h2m1 0h4m1 0h1m1 0h1m1 0h2m1 0h4m1 0h3m1 0h1m1 0h3M5 29.5h2m2 0h1m6 0h3m1 0h2m1 0h2m2 0h2m2 0h6m1 0h1m1 0h1M5 30.5h7m1 0h1m4 0h10m1 0h2m7 0h2M4 31.5h1m1 0h1m2 0h1m2 0h1m1 0h4m3 0h1m3 0h1m1 0h1m3 0h3m1 0h1m4 0h1M7 32.5h1m1 0h6m2 0h1m1 0h1m3 0h2m1 0h1m2 0h1m2 0h5m1 0h2M12 33.5h2m1 0h1m2 0h1m1 0h2m2 0h2m1 0h3m1 0h2m3 0h3m1 0h1M4 34.5h7m1 0h3m4 0h2m3 0h3m5 0h1m1 0h1m1 0h2m1 0h2M4 35.5h1m5 0h1m3 0h2m1 0h3m1 0h4m5 0h3m3 0h2M4 36.5h1m1 0h3m1 0h1m2 0h1m3 0h5m1 0h4m3 0h1m1 0h6M4 37.5h1m1 0h3m1 0h1m1 0h3m1 0h1m2 0h2m2 0h2m3 0h1m1 0h1m1 0h1m1 0h5M4 38.5h1m1 0h3m1 0h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h2m1 0h3m1 0h4m1 0h4m1 0h1M4 39.5h1m5 0h1m4 0h1m2 0h1m5 0h1m2 0h1m1 0h1m1 0h1m1 0h1m4 0h1M4 40.5h7m4 0h4m4 0h1m4 0h3m2 0h3m1 0h4'; +// 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; @@ -30,23 +22,18 @@ interface GetAppQrCodeProps { 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. `text-black` also feeds - // the logo, which draws in `currentColor`. + // 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 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/storybook/stories/components/GetAppButton.stories.tsx b/packages/storybook/stories/components/GetAppButton.stories.tsx index 1f1687175bb..54c6fdc46af 100644 --- a/packages/storybook/stories/components/GetAppButton.stories.tsx +++ b/packages/storybook/stories/components/GetAppButton.stories.tsx @@ -1,6 +1,8 @@ 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 { @@ -20,9 +22,16 @@ const meta: Meta = { 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. */} + +
+ +
+
), ],