A business card a phone can scan and save
A printed QR code that opens a card page and saves the contact, and what a Pixel taught me about vCard photos and Chrome on Android.
Giving someone my details in person meant reading them out. The public site now has a card: a printed QR code opens a page with my name, my role and the ways to reach me, and a button that saves the contact to the phone. An unlisted page shows the code large, for holding a phone out to somebody, or alone on the page for printing.
The code encodes a redirect#
A printed URL can never change, so the code does not encode the card's own address. It encodes a short path at the root of the site, which the web server answers with a redirect to the card. The page behind it can move, and a shorter payload is also a smaller symbol with larger modules. With error correction at level M, which survives a scuffed or partly covered print, the URL still fits in a version 2 code.
The redirect is where the language is chosen. Everywhere else on the site the language is in the path and never guessed from the browser, but a code carries one URL, so the phone's first preferred language is the only signal there is: French gets the French card, anything else the English one. The redirect is a 302 rather than a 301, because phones keep a permanent redirect for good and the card behind it may move. The response is not cached and varies on the language header, so no cache along the way can hand a French phone the English card.
Each build encodes its own origin, so a staging build prints a staging code. The banner that marks a build as not the live site stays on the printable code sheet, even on paper, so a staging code cannot pass for the real one. The card page itself drops the banner, since the person who scanned it gains nothing from a deployment notice.
The code is drawn once, at build time. A small build plugin encodes the payload, traces each row's runs of dark modules as rectangles in a single SVG path, and writes the code as SVG and as PNG. No QR library reaches a visitor, and the page shows the SVG as an image, which keeps its path out of a JavaScript budget that had no room left for it.
A vCard written by hand, carefully#
The contact file is a vCard 3.0 rather than 4.0: 3.0 is the version iOS and Android both import without asking anything, and 4.0 is newer without being better supported. The format is strict in three ways a hand-written string gets wrong, and each one is a contact that imports with a field missing or does not import at all. Lines end in CRLF. Commas and semicolons inside a value are escaped, since they separate the parts of a structured field. No line runs past 75 octets, and the folding counts UTF-8 octets and splits between characters, because a fold through a multi-byte character corrupts it.
Each language has its own file, because the job title is translated. The portrait is embedded as a JPEG: contacts apps ignore a photo given by URL, and none of them reads WebP. The website field names the production site on every build, because a saved contact outlives the deployment it was taken from.
What a Pixel said#
Every unit test passed, and on a Pixel the card failed at the one thing it was for. There were two separate problems.
Chrome on Android downloads any .vcf file, whatever its content type or disposition says. The
save button filed a file away in the downloads instead of offering the contact. On Android the
button now opens the phone's own new-contact screen through an insert intent, already filled in.
An intent carries flat strings only, so it has no photo and no website field; the website and the
LinkedIn profile go in the notes, and the file becomes a link under the button for anyone who
wants the photo too. The design system's link sanitiser rightly turns an intent: URL into #, so
the button keeps the file as its address and launches the intent from its click handler.
The second problem was worse. Opening that downloaded file put the photo's base64 into the work address, and the file was valid. Reading around got me to a theory about a blank line after the photo, held with medium confidence, and it was wrong. What settled it was the device: four variants of the file, each with a different given name so the contacts app would not merge them, imported in one round. A folded photo right after the address lands in the address, with or without a blank line. The same photo unfolded, or folded and moved up after the name, imports as the avatar. The file now carries the photo right after the name, followed by a blank line, which is the shape Android's own exporter writes and byte for byte the variant that worked.
Testing the intent end to end had a quirk of its own. In Chromium an intent: navigation leaves
the page where it is, and the failed request reports only intent:, with the fragment that holds
the values dropped. The test reads the full URL from the DevTools protocol's navigation event
instead.
The smaller fixes#
The code page's opening line is held to the width of the code. The French one otherwise re-wrapped when the brand typeface arrived, and centred text that re-wraps moves sideways as well as down, which tripled the page's layout shift.
The day after, the card started carrying my own email address rather than the practice's shared inbox, because the card introduces one person and the contact saved from it should reach that person. The rest of the site keeps the practice address.