From 863df026eb8baf40a71d9ec30ea756a33b94dcf6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 16:37:44 +0300 Subject: [PATCH 1/7] fix(get-app): point the QR at the OS-aware smart link with attribution The QR encoded daily.dev/apps, a landing page - one more tap between the scan and the store. It now encodes r.daily.dev/get, the same smart link the onboarding signup panel uses, which redirects by user agent straight to the App Store on iOS or Google Play on Android. utm_source=header_qr rides along because the redirect chain preserves query params end to end onto the final store URL (verified on both hops for both stores), so header scans stay distinguishable from onboarding scans - and on Google Play the utm param feeds install-referrer attribution. Same error-correction level H as before; scannability re-verified by decoding the rendered pixels at 96-160px. Co-Authored-By: Claude Fable 5 --- .../src/features/getApp/icons/getAppQr.svg | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/features/getApp/icons/getAppQr.svg b/packages/shared/src/features/getApp/icons/getAppQr.svg index cc13cf6389..14042c0034 100644 --- a/packages/shared/src/features/getApp/icons/getAppQr.svg +++ b/packages/shared/src/features/getApp/icons/getAppQr.svg @@ -1,9 +1,14 @@ - - + and keep ONLY the stroke path plus this viewBox. The white background rect + the generator adds must be dropped: the SVGR configs rewrite #ffffff to + currentcolor, which would turn it black; the parent plate supplies the + white. Note: double hyphens are illegal inside XML comments, so no long + CLI flags in this note. --> + From 4e36c9b7bd42f125bef9d160a484e61ff0902800 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 16:57:41 +0300 Subject: [PATCH 2/7] fix(get-app): correct attribution claims and add QR drift specs Addresses both review findings on the QR smart-link change: The asset comment over-claimed attribution. Following the chain end to end, Apple's locale redirect strips query params (and App Store attribution only honours pt/ct campaign tokens), and Play install referrer reads a referrer param, not a bare utm_source. What the param reliably does is reach our own redirector, which is what distinguishes header scans from onboarding scans. The comment now claims exactly that, and points store-side attribution at the redirector as the right place to map tokens per store. The destination URL previously lived only inside an opaque path string, in duplicate with the onboarding panel's APP_URL, with nothing in CI noticing if one moved. New specs parse each committed matrix back into modules and decode it with jsQR the way a scanner would, asserting it still says what the code claims - decoding rather than re-encoding keeps the assertion valid regardless of which generator or mask produced the asset (the onboarding matrix uses a different mask than a fresh encode picks, so strict matrix equality would false-positive there). Co-Authored-By: Claude Fable 5 --- packages/shared/__tests__/helpers/qr.ts | 96 +++++++++++++++++++ packages/shared/package.json | 1 + .../features/getApp/icons/getAppQr.spec.ts | 31 ++++++ .../src/features/getApp/icons/getAppQr.svg | 10 +- .../signupHero/LandingAppInstall.spec.tsx | 17 +++- .../signupHero/LandingAppInstall.tsx | 4 +- pnpm-lock.yaml | 8 ++ 7 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 packages/shared/__tests__/helpers/qr.ts create mode 100644 packages/shared/src/features/getApp/icons/getAppQr.spec.ts diff --git a/packages/shared/__tests__/helpers/qr.ts b/packages/shared/__tests__/helpers/qr.ts new file mode 100644 index 0000000000..757b6e1588 --- /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 0972a29239..7ed91eb415 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/features/getApp/icons/getAppQr.spec.ts b/packages/shared/src/features/getApp/icons/getAppQr.spec.ts new file mode 100644 index 0000000000..dfb3843fd4 --- /dev/null +++ b/packages/shared/src/features/getApp/icons/getAppQr.spec.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { + decodeMatrix, + parseStrokePath, +} from '../../../../__tests__/helpers/qr'; + +// The destination the committed matrix must encode. If this URL needs to +// change, regenerate the asset (instructions inside the svg) and update this +// constant in the same commit - this spec exists to make CI fail when only +// one of the two moves. +const EXPECTED_URL = 'https://r.daily.dev/get?utm_source=header_qr'; + +describe('getAppQr.svg', () => { + const svg = readFileSync(join(__dirname, 'getAppQr.svg'), 'utf8'); + const d = svg.match(/ { + expect(d).toBeTruthy(); + + expect(decodeMatrix(parseStrokePath(d))).toBe(EXPECTED_URL); + }); + + 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 14042c0034..88f248522f 100644 --- a/packages/shared/src/features/getApp/icons/getAppQr.svg +++ b/packages/shared/src/features/getApp/icons/getAppQr.svg @@ -2,8 +2,14 @@