diff --git a/packages/shared/__tests__/helpers/qr.ts b/packages/shared/__tests__/helpers/qr.ts
new file mode 100644
index 00000000000..757b6e15889
--- /dev/null
+++ b/packages/shared/__tests__/helpers/qr.ts
@@ -0,0 +1,96 @@
+import jsQR from 'jsqr';
+
+// The repo ships two hand-committed QR matrices (the header get-app asset and
+// the onboarding signup panel). Their destination URLs live in code, but the
+// matrices are opaque path strings - if one moves without the other being
+// regenerated, the only symptom is a scan landing on a stale destination.
+// These helpers parse a committed path back into a module matrix and decode it
+// the way a phone camera would (via jsQR), so a spec can assert the committed
+// pixels still say what the code claims. Decoding rather than re-encoding
+// keeps the assertion valid regardless of which generator or mask pattern
+// produced the asset.
+
+type Matrix = boolean[][];
+
+// Stroke-run format, as emitted by `npx qrcode -t svg`: rows drawn as
+// 1px-tall horizontal strokes on half-pixel centres, e.g.
+// `M4 4.5h7m2 0h2` = at row 4 draw 7 modules from col 4, skip 2, draw 2.
+// `quietZone` is the generator's fixed 4-module margin baked into the coords.
+export const parseStrokePath = (d: string, quietZone = 4): Matrix => {
+ const cells: Array<[number, number]> = [];
+ const rowRuns = d.split('M').filter(Boolean);
+
+ rowRuns.forEach((run) => {
+ const [, xStart, yCentre] = run.match(/^([\d.]+) ([\d.]+)/) ?? [];
+ const row = Math.floor(Number(yCentre)) - quietZone;
+ let col = Number(xStart) - quietZone;
+
+ Array.from(run.matchAll(/([hm])(-?[\d.]+)/g)).forEach(([, op, a]) => {
+ const n = Number(a);
+ if (op === 'h') {
+ for (let i = 0; i < n; i += 1) {
+ cells.push([row, col + i]);
+ }
+ }
+ col += n;
+ });
+ });
+
+ const size = Math.max(...cells.map(([row]) => row)) + 1;
+ const matrix: Matrix = Array.from({ length: size }, () =>
+ Array.from({ length: size }, () => false),
+ );
+ cells.forEach(([row, col]) => {
+ matrix[row][col] = true;
+ });
+
+ return matrix;
+};
+
+// Rect format, as used by the hand-flattened onboarding matrix:
+// `M0 0h7v1h-7z` = a 7-module run at row 0, col 0. Coordinates are already
+// module-space (the quiet zone lives in the viewBox origin instead).
+export const parseRectPath = (d: string): Matrix => {
+ const rects = Array.from(d.matchAll(/M(\d+) (\d+)h(\d+)v1h-\3z/g));
+ const size = Math.max(...rects.map(([, , y]) => Number(y))) + 1;
+ const matrix: Matrix = Array.from({ length: size }, () =>
+ Array.from({ length: size }, () => false),
+ );
+
+ rects.forEach(([, x, y, w]) => {
+ for (let i = 0; i < Number(w); i += 1) {
+ matrix[Number(y)][Number(x) + i] = true;
+ }
+ });
+
+ return matrix;
+};
+
+// Rasterises the matrix into greyscale RGBA (scaled up, with a real quiet
+// zone) and runs the same decoder a scanner pipeline would.
+export const decodeMatrix = (matrix: Matrix): string | null => {
+ const scale = 4;
+ const quiet = 4 * scale;
+ const px = matrix.length * scale + quiet * 2;
+ const rgba = new Uint8ClampedArray(px * px * 4).fill(255);
+
+ matrix.forEach((rowCells, row) => {
+ rowCells.forEach((dark, col) => {
+ if (!dark) {
+ return;
+ }
+ for (let dy = 0; dy < scale; dy += 1) {
+ for (let dx = 0; dx < scale; dx += 1) {
+ const x = quiet + col * scale + dx;
+ const y = quiet + row * scale + dy;
+ const at = (y * px + x) * 4;
+ rgba[at] = 0;
+ rgba[at + 1] = 0;
+ rgba[at + 2] = 0;
+ }
+ }
+ });
+ });
+
+ return jsQR(rgba, px, px)?.data ?? null;
+};
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 0972a29239c..7ed91eb415d 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -80,6 +80,7 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^12.3.0",
+ "jsqr": "^1.4.0",
"next": "16.2.6",
"nock": "^13.3.1",
"postcss": "^8.5.13",
diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx
index e3109e2802d..78251d27702 100644
--- a/packages/shared/src/components/layout/HeaderButtons.tsx
+++ b/packages/shared/src/components/layout/HeaderButtons.tsx
@@ -52,7 +52,6 @@ export function HeaderButtons({
{additionalButtons}
-
diff --git a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
index 8fc63f8bd01..1c80be88a92 100644
--- a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
+++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
@@ -4,25 +4,24 @@ 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 { isIOSNative } from '../../../lib/func';
import { appStoreUrl, playStoreUrl } from '../../../lib/constants';
-jest.mock('../../../hooks/useConditionalFeature', () => ({
- useConditionalFeature: jest.fn(),
+jest.mock('../../../lib/func', () => ({
+ ...jest.requireActual('../../../lib/func'),
+ isIOSNative: jest.fn(),
}));
-jest.mock('../../../hooks', () => ({
- ...jest.requireActual('../../../hooks'),
- useViewSize: jest.fn(),
-}));
-
-const mockUseConditionalFeature = useConditionalFeature as jest.Mock;
-const mockUseViewSize = useViewSize as jest.Mock;
+const mockIsIOSNative = isIOSNative as jest.Mock;
+// TestBootProvider defaults to a logged-in session; this surface is for
+// anonymous visitors, so the tests start logged out and opt in explicitly.
const renderComponent = (props = {}, auth = {}) =>
render(
-
+ ,
);
@@ -31,12 +30,11 @@ const triggerName = /get the daily\.dev mobile app/i;
beforeEach(() => {
jest.clearAllMocks();
- mockUseViewSize.mockReturnValue(true);
- mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false });
+ mockIsIOSNative.mockReturnValue(false);
});
describe('GetAppButton', () => {
- it('should render the trigger when the feature is enabled on desktop', () => {
+ it('should render the trigger for anonymous desktop visitors', () => {
renderComponent();
expect(
@@ -44,45 +42,44 @@ describe('GetAppButton', () => {
).toBeInTheDocument();
});
- it('should render nothing when the feature is off', () => {
- mockUseConditionalFeature.mockReturnValue({
- value: false,
- isLoading: false,
- });
- renderComponent();
+ it('should render nothing for logged-in users', () => {
+ renderComponent({}, { isLoggedIn: true });
expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
});
- it('should render nothing below laptop, where the app is already at hand', () => {
- mockUseViewSize.mockReturnValue(false);
+ // The desktop-only half of the gate is CSS, not JS, so the SSR HTML already
+ // contains the pill and hydration doesn't reflow Log in / Sign up. jsdom
+ // applies no stylesheets, so the assertable contract is the classes.
+ it('should gate the desktop breakpoint in CSS to avoid a hydration reflow', () => {
renderComponent();
- expect(
- screen.queryByRole('button', { name: triggerName }),
- ).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: triggerName })).toHaveClass(
+ 'hidden',
+ 'laptop:flex',
+ );
});
- // 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 });
+ // The wrappers render this same webapp shell and a tablet/desktop-mode
+ // viewport can satisfy the laptop breakpoint from inside the app, so both
+ // need a JS veto the CSS gate can't provide. iOS is detected through its
+ // WebKit runtime bridge.
+ it('should render nothing inside the iOS app even at laptop width', () => {
+ mockIsIOSNative.mockReturnValue(true);
+ renderComponent();
expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
});
- it('should not evaluate the flag when the caller already resolved it', () => {
- renderComponent({ isFeatureEnabled: false });
+ // Android has no isIOSNative()-style runtime bridge and is flagged through
+ // boot data instead.
+ it('should render nothing inside the Android app even at laptop width', () => {
+ renderComponent({}, { isAndroidApp: true });
- expect(mockUseConditionalFeature).toHaveBeenCalledWith(
- expect.objectContaining({ shouldEvaluate: false }),
- );
expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx
index a36ad04a845..249840ec5f6 100644
--- a/packages/shared/src/features/getApp/components/GetAppButton.tsx
+++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx
@@ -13,10 +13,6 @@ 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';
@@ -52,50 +48,33 @@ const stores: AppStore[] = [
},
];
-// 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.
+ // The logged-out header has room for the full label next to Log in /
+ // Sign up; icon-only (label in a tooltip) exists as the compact alternative.
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 { isLoggedIn, isAndroidApp } = useAuthContext();
+ // This entry point is for *anonymous desktop* visitors only - logged-in
+ // users made a product call to keep their action rail clean, so the gate
+ // lives here rather than trusting every call site. The desktop half is
+ // gated in CSS (`hidden laptop:flex` on the trigger, matching LoginButton
+ // and ProfileButton in the same row) so the server HTML already contains
+ // the pill and hydration doesn't reflow Log in / Sign up on first paint.
+ // The native wrappers still need JS vetoes on top: they render this same
+ // webapp shell, a tablet/desktop-mode viewport can satisfy the laptop
+ // breakpoint from inside the app, and nobody should be told to go get an
+ // app they're already holding. iOS exposes a WebKit bridge at runtime
+ // (isIOSNative); Android has no such bridge and is flagged through boot
+ // data instead.
+ const shouldRender = !isLoggedIn && !isIOSNative() && !isAndroidApp;
const onOpenChange = useCallback(
(open: boolean) => {
@@ -105,13 +84,12 @@ export function GetAppButton({
return;
}
- setHasSeen(true);
logEvent({
event_name: LogEvent.Click,
target_type: TargetType.GetAppButton,
});
},
- [logEvent, setHasSeen],
+ [logEvent],
);
const onStoreClick = useCallback(
@@ -125,45 +103,31 @@ export function GetAppButton({
[logEvent],
);
- if (!shouldRender || !isFeatureEnabled) {
+ if (!shouldRender) {
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 && (
-
- )}
-
+
+
+ }
+ aria-haspopup="dialog"
+ aria-expanded={isOpen}
+ aria-label="Get the daily.dev mobile app"
+ >
+ {showLabel ? 'Get the app' : null}
+
+
+ {
+ const svg = readFileSync(join(__dirname, 'getAppQr.svg'), 'utf8');
+ const d = svg.match(/ {
+ expect(d).toBeTruthy();
+
+ expect(decodeMatrix(parseStrokePath(d))).toBe(EXPECTED_URL);
+ });
+
+ it('should keep the module count error correction H produces', () => {
+ expect(parseStrokePath(d).length).toBe(EXPECTED_SIZE);
+ });
+
+ it('should declare a viewBox matching the matrix plus the quiet zone', () => {
+ const viewBox = svg.match(/viewBox="0 0 (\d+) (\d+)"/);
+ const size = parseStrokePath(d).length;
+
+ expect(Number(viewBox?.[1])).toBe(size + 8);
+ expect(Number(viewBox?.[2])).toBe(size + 8);
+ });
+});
diff --git a/packages/shared/src/features/getApp/icons/getAppQr.svg b/packages/shared/src/features/getApp/icons/getAppQr.svg
index cc13cf6389d..3572dd8c781 100644
--- a/packages/shared/src/features/getApp/icons/getAppQr.svg
+++ b/packages/shared/src/features/getApp/icons/getAppQr.svg
@@ -1,9 +1,23 @@
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
index 111b4392d81..5499bd450b4 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
@@ -1,6 +1,15 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
-import { APP_URL, LandingAppInstall, VISIBLE_LABEL } from './LandingAppInstall';
+import {
+ APP_URL,
+ LandingAppInstall,
+ QR_PATH,
+ VISIBLE_LABEL,
+} from './LandingAppInstall';
+import {
+ decodeMatrix,
+ parseRectPath,
+} from '../../../../../__tests__/helpers/qr';
describe('LandingAppInstall', () => {
it('exposes the app-store destination as a link, not only as a QR code', () => {
@@ -32,4 +41,16 @@ describe('LandingAppInstall', () => {
screen.getByRole('link', { name: /iOS or Android/i }),
).toBeInTheDocument();
});
+ // The matrix is a hand-flattened constant while the destination lives in
+ // APP_URL; nothing else notices if one moves without the other. Decoding the
+ // committed modules the way a scanner would makes CI catch that drift.
+ it('keeps the QR matrix in sync with APP_URL at error correction H', () => {
+ const matrix = parseRectPath(QR_PATH);
+
+ expect(decodeMatrix(matrix)).toBe(APP_URL);
+ // Decoding alone would still pass at a weaker level, and the logo badge
+ // overlaps modules that only H's recovery budget can spare. 29 modules is
+ // version 3 at H for this URL; a weaker level shrinks the matrix.
+ expect(matrix.length).toBe(29);
+ });
});
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
index d2c48ba5f23..4a549e331ba 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
@@ -34,7 +34,9 @@ const QR_MODULES = 29;
// inside the same box. Level H's 30% recovery covers the shortfall.
const QR_QUIET_ZONE = 2;
const QR_SIZE = QR_MODULES + QR_QUIET_ZONE * 2;
-const QR_PATH =
+// Exported so the spec can parse it back into a module matrix and assert it
+// still encodes APP_URL (see __tests__/helpers/qr.ts).
+export const QR_PATH =
'M0 0h7v1h-7zM9 0h1v1h-1zM13 0h1v1h-1zM16 0h1v1h-1zM18 0h1v1h-1zM22 0h7v1h-7zM0 1h1v1h-1zM6 1h1v1h-1zM12 1h2v1h-2zM15 1h1v1h-1zM17 1h2v1h-2zM20 1h1v1h-1zM22 1h1v1h-1zM28 1h1v1h-1zM0 2h1v1h-1zM2 2h3v1h-3zM6 2h1v1h-1zM9 2h2v1h-2zM13 2h1v1h-1zM16 2h1v1h-1zM19 2h2v1h-2zM22 2h1v1h-1zM24 2h3v1h-3zM28 2h1v1h-1zM0 3h1v1h-1zM2 3h3v1h-3zM6 3h1v1h-1zM12 3h3v1h-3zM17 3h1v1h-1zM22 3h1v1h-1zM24 3h3v1h-3zM28 3h1v1h-1zM0 4h1v1h-1zM2 4h3v1h-3zM6 4h1v1h-1zM8 4h1v1h-1zM10 4h1v1h-1zM12 4h1v1h-1zM14 4h2v1h-2zM17 4h1v1h-1zM19 4h1v1h-1zM22 4h1v1h-1zM24 4h3v1h-3zM28 4h1v1h-1zM0 5h1v1h-1zM6 5h1v1h-1zM10 5h2v1h-2zM13 5h1v1h-1zM15 5h1v1h-1zM19 5h1v1h-1zM22 5h1v1h-1zM28 5h1v1h-1zM0 6h7v1h-7zM8 6h1v1h-1zM10 6h1v1h-1zM12 6h1v1h-1zM14 6h1v1h-1zM16 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM22 6h7v1h-7zM8 7h2v1h-2zM11 7h1v1h-1zM13 7h1v1h-1zM15 7h1v1h-1zM17 7h1v1h-1zM19 7h1v1h-1zM2 8h2v1h-2zM6 8h3v1h-3zM20 8h3v1h-3zM24 8h1v1h-1zM0 9h2v1h-2zM7 9h5v1h-5zM17 9h4v1h-4zM22 9h5v1h-5zM28 9h1v1h-1zM1 10h4v1h-4zM6 10h1v1h-1zM13 10h1v1h-1zM15 10h4v1h-4zM22 10h1v1h-1zM26 10h2v1h-2zM0 11h3v1h-3zM9 11h1v1h-1zM11 11h2v1h-2zM15 11h6v1h-6zM23 11h1v1h-1zM28 11h1v1h-1zM0 12h1v1h-1zM3 12h2v1h-2zM6 12h1v1h-1zM9 12h3v1h-3zM13 12h2v1h-2zM16 12h2v1h-2zM21 12h1v1h-1zM23 12h1v1h-1zM25 12h4v1h-4zM0 13h2v1h-2zM5 13h1v1h-1zM8 13h7v1h-7zM17 13h4v1h-4zM22 13h2v1h-2zM25 13h2v1h-2zM28 13h1v1h-1zM1 14h2v1h-2zM4 14h1v1h-1zM6 14h4v1h-4zM11 14h1v1h-1zM13 14h2v1h-2zM16 14h1v1h-1zM18 14h2v1h-2zM21 14h5v1h-5zM27 14h2v1h-2zM1 15h1v1h-1zM4 15h2v1h-2zM7 15h1v1h-1zM10 15h6v1h-6zM19 15h1v1h-1zM21 15h2v1h-2zM24 15h2v1h-2zM0 16h2v1h-2zM4 16h1v1h-1zM6 16h6v1h-6zM13 16h1v1h-1zM16 16h2v1h-2zM20 16h1v1h-1zM23 16h3v1h-3zM28 16h1v1h-1zM1 17h1v1h-1zM3 17h2v1h-2zM9 17h4v1h-4zM16 17h1v1h-1zM18 17h1v1h-1zM20 17h1v1h-1zM23 17h1v1h-1zM25 17h2v1h-2zM0 18h1v1h-1zM2 18h6v1h-6zM10 18h1v1h-1zM12 18h2v1h-2zM15 18h1v1h-1zM18 18h1v1h-1zM20 18h1v1h-1zM22 18h1v1h-1zM24 18h2v1h-2zM4 19h2v1h-2zM7 19h2v1h-2zM15 19h2v1h-2zM19 19h1v1h-1zM21 19h2v1h-2zM26 19h3v1h-3zM1 20h3v1h-3zM5 20h2v1h-2zM13 20h1v1h-1zM17 20h1v1h-1zM19 20h10v1h-10zM8 21h1v1h-1zM10 21h1v1h-1zM15 21h1v1h-1zM17 21h4v1h-4zM24 21h2v1h-2zM27 21h2v1h-2zM0 22h7v1h-7zM8 22h1v1h-1zM10 22h3v1h-3zM16 22h2v1h-2zM19 22h2v1h-2zM22 22h1v1h-1zM24 22h1v1h-1zM26 22h2v1h-2zM0 23h1v1h-1zM6 23h1v1h-1zM9 23h1v1h-1zM11 23h1v1h-1zM15 23h4v1h-4zM20 23h1v1h-1zM24 23h1v1h-1zM27 23h2v1h-2zM0 24h1v1h-1zM2 24h3v1h-3zM6 24h1v1h-1zM9 24h5v1h-5zM17 24h2v1h-2zM20 24h8v1h-8zM0 25h1v1h-1zM2 25h3v1h-3zM6 25h1v1h-1zM8 25h2v1h-2zM11 25h2v1h-2zM16 25h4v1h-4zM24 25h2v1h-2zM27 25h1v1h-1zM0 26h1v1h-1zM2 26h3v1h-3zM6 26h1v1h-1zM8 26h1v1h-1zM10 26h2v1h-2zM13 26h1v1h-1zM20 26h1v1h-1zM23 26h1v1h-1zM25 26h2v1h-2zM28 26h1v1h-1zM0 27h1v1h-1zM6 27h1v1h-1zM10 27h3v1h-3zM14 27h2v1h-2zM17 27h1v1h-1zM20 27h1v1h-1zM22 27h1v1h-1zM24 27h2v1h-2zM27 27h1v1h-1zM0 28h7v1h-7zM9 28h1v1h-1zM12 28h2v1h-2zM16 28h2v1h-2zM19 28h5v1h-5zM27 28h1v1h-1z';
export const LandingAppInstall = ({
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 8973137660b..0d244dab67a 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -323,9 +323,3 @@ 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/storybook/stories/components/GetAppButton.stories.tsx b/packages/storybook/stories/components/GetAppButton.stories.tsx
index 54c6fdc46af..b05674af04d 100644
--- a/packages/storybook/stories/components/GetAppButton.stories.tsx
+++ b/packages/storybook/stories/components/GetAppButton.stories.tsx
@@ -9,7 +9,6 @@ import {
Button,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/Button';
-import { BellIcon } from '@dailydotdev/shared/src/components/icons';
const queryClient = new QueryClient();
@@ -22,11 +21,14 @@ 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. */}
+ {/* The render gate reads isLoggedIn and isAndroidApp from AuthContext
+ (the Android wrapper has no runtime bridge), so the story must
+ provide the anonymous context the button ships to. */}
@@ -35,8 +37,6 @@ const meta: Meta = {
),
],
- // Storybook has no GrowthBook instance, so the flag is forced on here.
- args: { isFeatureEnabled: true },
};
export default meta;
@@ -55,21 +55,6 @@ const HeaderShell = ({
);
-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 => (
@@ -89,9 +74,10 @@ export const InHeaderLoggedOut: Story = {
),
};
-// 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.
+// Same slot, icon-only. NOT shipped - kept as the reference for the compact
+// variant, 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) => (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f5e1cf35fd3..a63f4fc3d14 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -659,6 +659,9 @@ importers:
jest-junit:
specifier: ^12.3.0
version: 12.3.0
+ jsqr:
+ specifier: ^1.4.0
+ version: 1.4.0
next:
specifier: 16.2.6
version: 16.2.6(@babel/core@7.26.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.82.0)
@@ -7605,6 +7608,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
+ jsqr@1.4.0:
+ resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
+
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -17619,6 +17625,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jsqr@1.4.0: {}
+
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.8