An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

Select the types of activity you want to include in your feed.

docs: implementation plan for client-side QR decode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

authored by

Brittany Ellich
Claude Opus 4.8
and committed by
Tangled
(Jun 1, 2026, 2:54 PM UTC) a1ea8d5a 02fab8df

+668
+668
docs/superpowers/plans/2026-06-01-qr-decode.md
··· 1 + # Client-side QR Decode Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Turn the existing mobile camera button into a working live QR scanner that opens the rear camera, decodes atmo.quest connect codes with jsQR, and navigates to them. 6 + 7 + **Architecture:** All work is client-side plus templ markup tweaks and CSS — no Go handler/route changes. A new `qr-scan.js` module builds a fullscreen overlay, runs `getUserMedia({facingMode:'environment'})`, decodes each frame with the vendored jsQR library, and on a valid `/c/{did}` or `/c/l/{id}` code navigates to that path on the current origin (the existing `/c/...` handler then connects). Non-connect codes are ignored with a transient hint; if the live camera is unavailable it falls back to a single still capture via a hidden file input, decoded the same way. 8 + 9 + **Tech Stack:** Vanilla JS (matching `web/resources/static/js/*.js`), jsQR 1.4.0 (vendored, MIT), templ, Go static asset server, `node --test` for the one pure function. 10 + 11 + Reference spec: `docs/superpowers/specs/2026-06-01-qr-decode-design.md` 12 + 13 + --- 14 + 15 + ## File structure 16 + 17 + - `web/resources/static/js/qr-scan.js` — **new.** The scanner module. Contains the pure `parseConnectPath` function (unit-tested) plus all browser-only overlay/camera/decode/fallback wiring. DOM code is guarded so the file can be `require`d in Node for testing. 18 + - `web/resources/static/js/qr-scan.test.js` — **new.** Node test for `parseConnectPath`. 19 + - `web/resources/static/js/vendor/jsqr.js` — **new.** Vendored jsQR UMD build. 20 + - `web/resources/static/css/terminal.css` — **modify.** Update the `.camera-scan` rule for the new wrapper element; add overlay styles. 21 + - `features/common/layouts/camerabutton.templ` — **modify.** Button markup change. 22 + - `features/common/layouts/base.templ` — **modify.** Add two `<script defer>` includes. 23 + - `features/index/pages/index.templ` — **modify.** Add the shared button to the home dashboard. 24 + 25 + No `sw.js` change: the service worker already serves `/static/*` cache-first, so the new JS is cached automatically on first load. 26 + 27 + --- 28 + 29 + ## Task 1: Pure connect-URL matcher + tests (TDD) 30 + 31 + **Files:** 32 + - Create: `web/resources/static/js/qr-scan.js` 33 + - Test: `web/resources/static/js/qr-scan.test.js` 34 + 35 + - [ ] **Step 1: Write the failing test** 36 + 37 + Create `web/resources/static/js/qr-scan.test.js`: 38 + 39 + ```js 40 + const test = require("node:test"); 41 + const assert = require("node:assert"); 42 + const { parseConnectPath } = require("./qr-scan.js"); 43 + 44 + const ORIGIN = "http://127.0.0.1:9090"; 45 + 46 + test("matches /c/{did} and returns the same-origin path", () => { 47 + assert.strictEqual( 48 + parseConnectPath("https://atmo.quest/c/did:plc:abc", ORIGIN), 49 + "/c/did:plc:abc", 50 + ); 51 + }); 52 + 53 + test("matches /c/l/{local_id}", () => { 54 + assert.strictEqual( 55 + parseConnectPath("https://atmo.quest/c/l/local_abc-123", ORIGIN), 56 + "/c/l/local_abc-123", 57 + ); 58 + }); 59 + 60 + test("drops a foreign origin, keeps the connect path", () => { 61 + assert.strictEqual( 62 + parseConnectPath("https://prod.example.com/c/did:plc:xyz", ORIGIN), 63 + "/c/did:plc:xyz", 64 + ); 65 + }); 66 + 67 + test("preserves query and hash", () => { 68 + assert.strictEqual( 69 + parseConnectPath("https://atmo.quest/c/did:plc:abc?x=1#y", ORIGIN), 70 + "/c/did:plc:abc?x=1#y", 71 + ); 72 + }); 73 + 74 + test("rejects a non-connect URL", () => { 75 + assert.strictEqual(parseConnectPath("https://example.com/foo", ORIGIN), null); 76 + }); 77 + 78 + test("rejects plain garbage text", () => { 79 + assert.strictEqual(parseConnectPath("just some text $$$", ORIGIN), null); 80 + }); 81 + 82 + test("rejects the bare /c/l with no id", () => { 83 + assert.strictEqual(parseConnectPath("https://atmo.quest/c/l", ORIGIN), null); 84 + }); 85 + ``` 86 + 87 + - [ ] **Step 2: Run the test to verify it fails** 88 + 89 + Run: `node --test web/resources/static/js/qr-scan.test.js` 90 + Expected: FAIL — `Cannot find module './qr-scan.js'`. 91 + 92 + - [ ] **Step 3: Create `qr-scan.js` with the pure function only** 93 + 94 + Create `web/resources/static/js/qr-scan.js`: 95 + 96 + ```js 97 + // qr-scan.js — live QR scanner for the "scan to connect" button. 98 + // 99 + // Tapping any [data-qr-scan] button opens a fullscreen overlay with the rear 100 + // camera, decodes frames with jsQR, and navigates to a decoded atmo.quest 101 + // connect URL (/c/{did} or /c/l/{id}) on the current origin. Non-connect codes 102 + // are ignored with a transient hint. If the live camera is unavailable or 103 + // permission is denied, it falls back to a single still capture via the 104 + // button's hidden <input type="file" capture="environment">, decoded the same 105 + // way. 106 + 107 + (function () { 108 + "use strict"; 109 + 110 + // --- Pure URL matching (unit-tested) -------------------------------- 111 + // Returns the same-origin path to navigate to for an atmo.quest connect 112 + // QR, or null if `text` is not one of our connect URLs. The origin is 113 + // dropped on purpose so a QR baked with the production PublicURL still 114 + // works when scanned against a local/dev origin. 115 + function parseConnectPath(text, origin) { 116 + let url; 117 + try { 118 + url = new URL(text, origin); 119 + } catch (e) { 120 + return null; 121 + } 122 + const m = url.pathname.match(/^\/c\/(?:l\/([^/]+)|([^/]+))\/?$/); 123 + if (!m) return null; 124 + // Reject the bare "/c/l" case (segment captured as "l", no local id). 125 + if (!m[1] && m[2] === "l") return null; 126 + return url.pathname + url.search + url.hash; 127 + } 128 + 129 + // Export for Node tests; harmless in the browser (no `module`). 130 + if (typeof module !== "undefined" && module.exports) { 131 + module.exports = { parseConnectPath }; 132 + } 133 + 134 + // Browser-only wiring is added in a later task; bail out under Node. 135 + if (typeof document === "undefined") return; 136 + })(); 137 + ``` 138 + 139 + - [ ] **Step 4: Run the test to verify it passes** 140 + 141 + Run: `node --test web/resources/static/js/qr-scan.test.js` 142 + Expected: PASS — 7 tests pass. 143 + 144 + - [ ] **Step 5: Commit** 145 + 146 + ```bash 147 + git add web/resources/static/js/qr-scan.js web/resources/static/js/qr-scan.test.js 148 + git commit -m "feat(qr): add connect-URL matcher with tests" 149 + ``` 150 + 151 + --- 152 + 153 + ## Task 2: Vendor the jsQR library 154 + 155 + **Files:** 156 + - Create: `web/resources/static/js/vendor/jsqr.js` 157 + 158 + - [ ] **Step 1: Download jsQR 1.4.0 into the vendor directory** 159 + 160 + Run: 161 + ```bash 162 + mkdir -p web/resources/static/js/vendor 163 + curl -fsSL "https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js" -o web/resources/static/js/vendor/jsqr.js 164 + ``` 165 + 166 + - [ ] **Step 2: Verify the file downloaded and exposes the global** 167 + 168 + Run: 169 + ```bash 170 + test -s web/resources/static/js/vendor/jsqr.js && grep -c 'root\["jsQR"\]' web/resources/static/js/vendor/jsqr.js 171 + ``` 172 + Expected: prints `1` (the UMD build assigns `window.jsQR`). If the file is empty or the count is `0`, the download failed — retry, or fetch from `https://unpkg.com/jsqr@1.4.0/dist/jsQR.js`. 173 + 174 + - [ ] **Step 3: Commit** 175 + 176 + ```bash 177 + git add web/resources/static/js/vendor/jsqr.js 178 + git commit -m "chore(qr): vendor jsQR 1.4.0 (MIT)" 179 + ``` 180 + 181 + --- 182 + 183 + ## Task 3: Build the scanner overlay + camera/decode/fallback logic 184 + 185 + **Files:** 186 + - Modify: `web/resources/static/js/qr-scan.js` 187 + 188 + - [ ] **Step 1: Replace the browser-only bail-out with the full wiring** 189 + 190 + In `web/resources/static/js/qr-scan.js`, replace this block: 191 + 192 + ```js 193 + // Browser-only wiring is added in a later task; bail out under Node. 194 + if (typeof document === "undefined") return; 195 + })(); 196 + ``` 197 + 198 + with: 199 + 200 + ```js 201 + // Browser-only wiring below; bail out under Node (used only for tests). 202 + if (typeof document === "undefined") return; 203 + 204 + let activeStream = null; 205 + let rafId = null; 206 + let overlayEl = null; 207 + let hintTimer = null; 208 + 209 + function stopCamera() { 210 + if (rafId !== null) { 211 + cancelAnimationFrame(rafId); 212 + rafId = null; 213 + } 214 + if (activeStream) { 215 + activeStream.getTracks().forEach((t) => t.stop()); 216 + activeStream = null; 217 + } 218 + } 219 + 220 + function closeOverlay() { 221 + stopCamera(); 222 + if (hintTimer) { 223 + clearTimeout(hintTimer); 224 + hintTimer = null; 225 + } 226 + if (overlayEl && overlayEl.parentNode) { 227 + overlayEl.parentNode.removeChild(overlayEl); 228 + } 229 + overlayEl = null; 230 + } 231 + 232 + function navigateTo(path) { 233 + stopCamera(); 234 + window.location.assign(path); 235 + } 236 + 237 + function showHint(hintEl, msg) { 238 + if (!hintEl) return; 239 + hintEl.textContent = msg; 240 + hintEl.classList.add("show"); 241 + if (hintTimer) clearTimeout(hintTimer); 242 + hintTimer = setTimeout(() => hintEl.classList.remove("show"), 2000); 243 + } 244 + 245 + function buildOverlay() { 246 + const overlay = document.createElement("div"); 247 + overlay.className = "qr-scan-overlay"; 248 + overlay.innerHTML = 249 + '<div class="qr-scan-backdrop" data-qr-close></div>' + 250 + '<div class="qr-scan-stage">' + 251 + '<video class="qr-scan-video" playsinline muted></video>' + 252 + '<div class="qr-scan-frame"></div>' + 253 + '<p class="qr-scan-hint" role="status"></p>' + 254 + "</div>" + 255 + '<button type="button" class="btn btn-ghost qr-scan-cancel" data-qr-close>cancel</button>'; 256 + document.body.appendChild(overlay); 257 + overlay 258 + .querySelectorAll("[data-qr-close]") 259 + .forEach((el) => el.addEventListener("click", closeOverlay)); 260 + return overlay; 261 + } 262 + 263 + function decodeLoop(video, canvas, ctx, hintEl) { 264 + if (!activeStream) return; // overlay was closed 265 + if (video.readyState === video.HAVE_ENOUGH_DATA) { 266 + const w = video.videoWidth; 267 + const h = video.videoHeight; 268 + if (w && h) { 269 + canvas.width = w; 270 + canvas.height = h; 271 + ctx.drawImage(video, 0, 0, w, h); 272 + const img = ctx.getImageData(0, 0, w, h); 273 + const result = window.jsQR ? window.jsQR(img.data, w, h) : null; 274 + if (result && result.data) { 275 + const path = parseConnectPath(result.data, window.location.origin); 276 + if (path) { 277 + navigateTo(path); 278 + return; 279 + } 280 + showHint(hintEl, "not an atmo.quest code — keep scanning"); 281 + } 282 + } 283 + } 284 + rafId = requestAnimationFrame(() => decodeLoop(video, canvas, ctx, hintEl)); 285 + } 286 + 287 + function fallbackToFile(fileInput, hintEl) { 288 + showHint(hintEl, "camera unavailable — tap to take a photo of the QR"); 289 + if (fileInput) fileInput.click(); 290 + } 291 + 292 + async function startLiveScan(fileInput) { 293 + overlayEl = buildOverlay(); 294 + const video = overlayEl.querySelector(".qr-scan-video"); 295 + const hintEl = overlayEl.querySelector(".qr-scan-hint"); 296 + const canvas = document.createElement("canvas"); 297 + const ctx = canvas.getContext("2d", { willReadFrequently: true }); 298 + 299 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 300 + fallbackToFile(fileInput, hintEl); 301 + return; 302 + } 303 + try { 304 + activeStream = await navigator.mediaDevices.getUserMedia({ 305 + video: { facingMode: "environment" }, 306 + audio: false, 307 + }); 308 + } catch (err) { 309 + fallbackToFile(fileInput, hintEl); 310 + return; 311 + } 312 + video.srcObject = activeStream; 313 + await video.play().catch(() => {}); 314 + rafId = requestAnimationFrame(() => decodeLoop(video, canvas, ctx, hintEl)); 315 + } 316 + 317 + function decodeImageFile(file) { 318 + const reader = new FileReader(); 319 + reader.onload = function () { 320 + const img = new Image(); 321 + img.onload = function () { 322 + const canvas = document.createElement("canvas"); 323 + canvas.width = img.naturalWidth; 324 + canvas.height = img.naturalHeight; 325 + const ctx = canvas.getContext("2d", { willReadFrequently: true }); 326 + ctx.drawImage(img, 0, 0); 327 + const data = ctx.getImageData(0, 0, canvas.width, canvas.height); 328 + const result = window.jsQR 329 + ? window.jsQR(data.data, canvas.width, canvas.height) 330 + : null; 331 + const hintEl = overlayEl && overlayEl.querySelector(".qr-scan-hint"); 332 + if (result && result.data) { 333 + const path = parseConnectPath(result.data, window.location.origin); 334 + if (path) { 335 + navigateTo(path); 336 + return; 337 + } 338 + } 339 + showHint(hintEl, "no atmo.quest code found — try again"); 340 + }; 341 + img.src = reader.result; 342 + }; 343 + reader.readAsDataURL(file); 344 + } 345 + 346 + function initQrScan() { 347 + document.querySelectorAll("[data-qr-scan]").forEach((btn) => { 348 + const fileInput = btn.parentNode 349 + ? btn.parentNode.querySelector('input[type="file"]') 350 + : null; 351 + btn.addEventListener("click", function (e) { 352 + e.preventDefault(); 353 + startLiveScan(fileInput); 354 + }); 355 + if (fileInput) { 356 + fileInput.addEventListener("change", function () { 357 + if (fileInput.files && fileInput.files[0]) { 358 + decodeImageFile(fileInput.files[0]); 359 + } 360 + }); 361 + } 362 + }); 363 + } 364 + 365 + if (document.readyState === "loading") { 366 + document.addEventListener("DOMContentLoaded", initQrScan); 367 + } else { 368 + initQrScan(); 369 + } 370 + })(); 371 + ``` 372 + 373 + - [ ] **Step 2: Re-run the pure-function test (the Node require must still work)** 374 + 375 + Run: `node --test web/resources/static/js/qr-scan.test.js` 376 + Expected: PASS — 7 tests pass (the DOM code is skipped under Node because `document` is undefined). 377 + 378 + - [ ] **Step 3: Lint-check the file parses as valid JS** 379 + 380 + Run: `node --check web/resources/static/js/qr-scan.js` 381 + Expected: no output, exit 0. 382 + 383 + - [ ] **Step 4: Commit** 384 + 385 + ```bash 386 + git add web/resources/static/js/qr-scan.js 387 + git commit -m "feat(qr): live camera overlay, decode loop, and file fallback" 388 + ``` 389 + 390 + --- 391 + 392 + ## Task 4: Overlay + button CSS 393 + 394 + **Files:** 395 + - Modify: `web/resources/static/css/terminal.css:219-230` (the existing `.camera-scan` block) 396 + 397 + - [ ] **Step 1: Update the `.camera-scan` rule and add overlay styles** 398 + 399 + In `web/resources/static/css/terminal.css`, replace this block: 400 + 401 + ```css 402 + /* Mobile-only camera-launch button (profile + event screens). 403 + Hidden by default; shown only on mobile, matching the .bottom-nav 404 + breakpoint (max-width: 768px). */ 405 + .camera-scan { 406 + display: none; 407 + } 408 + @media (max-width: 768px) { 409 + .camera-scan { 410 + display: inline-flex; 411 + margin: 12px auto 0; 412 + } 413 + } 414 + ``` 415 + 416 + with: 417 + 418 + ```css 419 + /* Mobile-only "scan to connect" button (profile + event + home screens). 420 + The wrapper is hidden by default and shown only on mobile, matching the 421 + .bottom-nav breakpoint (max-width: 768px). */ 422 + .camera-scan { 423 + display: none; 424 + } 425 + @media (max-width: 768px) { 426 + .camera-scan { 427 + display: flex; 428 + justify-content: center; 429 + margin: 12px 0 0; 430 + } 431 + } 432 + 433 + /* Fullscreen live QR scanner overlay (built by js/qr-scan.js). */ 434 + .qr-scan-overlay { 435 + position: fixed; 436 + inset: 0; 437 + z-index: 1000; 438 + display: flex; 439 + flex-direction: column; 440 + align-items: center; 441 + justify-content: center; 442 + background: rgba(0, 0, 0, 0.92); 443 + } 444 + .qr-scan-backdrop { 445 + position: absolute; 446 + inset: 0; 447 + } 448 + .qr-scan-stage { 449 + position: relative; 450 + z-index: 1; 451 + width: min(90vw, 90vh); 452 + max-width: 480px; 453 + aspect-ratio: 1 / 1; 454 + } 455 + .qr-scan-video { 456 + width: 100%; 457 + height: 100%; 458 + object-fit: cover; 459 + border-radius: 8px; 460 + background: #000; 461 + } 462 + .qr-scan-frame { 463 + position: absolute; 464 + inset: 12%; 465 + border: 2px solid rgba(255, 255, 255, 0.9); 466 + border-radius: 8px; 467 + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.25); 468 + pointer-events: none; 469 + } 470 + .qr-scan-hint { 471 + position: absolute; 472 + left: 0; 473 + right: 0; 474 + bottom: -2.4rem; 475 + text-align: center; 476 + color: #fff; 477 + font-size: 0.85rem; 478 + opacity: 0; 479 + transition: opacity 0.2s; 480 + } 481 + .qr-scan-hint.show { 482 + opacity: 1; 483 + } 484 + .qr-scan-cancel { 485 + position: relative; 486 + z-index: 1; 487 + margin-top: 2.6rem; 488 + } 489 + ``` 490 + 491 + - [ ] **Step 2: Commit** 492 + 493 + ```bash 494 + git add web/resources/static/css/terminal.css 495 + git commit -m "feat(qr): scanner overlay and mobile button styles" 496 + ``` 497 + 498 + --- 499 + 500 + ## Task 5: Update the CameraScanButton component 501 + 502 + **Files:** 503 + - Modify: `features/common/layouts/camerabutton.templ` 504 + 505 + - [ ] **Step 1: Replace the component markup** 506 + 507 + Replace the entire contents of `features/common/layouts/camerabutton.templ` with: 508 + 509 + ```templ 510 + package layouts 511 + 512 + // CameraScanButton renders a mobile-only "scan to connect" button. Tapping it 513 + // opens a fullscreen live QR scanner (js/qr-scan.js): the rear camera streams, 514 + // frames are decoded with jsQR, and a scanned atmo.quest connect code 515 + // (/c/{did} or /c/l/{id}) navigates straight into the connect flow. 516 + // 517 + // The hidden file input is a fallback: if the live camera is unavailable or 518 + // the user denies permission, qr-scan.js triggers it so the user can snap a 519 + // single photo of the QR, which is decoded the same way. 520 + // 521 + // Visibility is controlled entirely by CSS: the .camera-scan wrapper is 522 + // display:none by default and only shown under @media (max-width: 768px), 523 + // matching the .bottom-nav mobile breakpoint. 524 + templ CameraScanButton() { 525 + <div class="camera-scan"> 526 + <button type="button" class="btn btn-ghost" data-qr-scan>📷 scan to connect</button> 527 + <input type="file" accept="image/*" capture="environment" hidden/> 528 + </div> 529 + } 530 + ``` 531 + 532 + - [ ] **Step 2: Regenerate templ** 533 + 534 + Run: `go tool templ generate -f features/common/layouts/camerabutton.templ` 535 + Expected: exit 0. 536 + 537 + - [ ] **Step 3: Verify the build compiles** 538 + 539 + Run: `go build ./...` 540 + Expected: exit 0, no output. 541 + 542 + - [ ] **Step 4: Commit** 543 + 544 + ```bash 545 + git add features/common/layouts/camerabutton.templ features/common/layouts/camerabutton_templ.go 546 + git commit -m "feat(qr): turn camera button into a scan-to-connect trigger" 547 + ``` 548 + 549 + --- 550 + 551 + ## Task 6: Load the scanner scripts in the base layout 552 + 553 + **Files:** 554 + - Modify: `features/common/layouts/base.templ:38-39` (after the existing JS includes) 555 + 556 + - [ ] **Step 1: Add the two script includes** 557 + 558 + In `features/common/layouts/base.templ`, find: 559 + 560 + ```templ 561 + <script src={ resources.StaticPath("js/badge-toast.js") } defer></script> 562 + <script src={ resources.StaticPath("js/handle-autocomplete.js") } defer></script> 563 + ``` 564 + 565 + and add the two lines immediately after them so the block reads: 566 + 567 + ```templ 568 + <script src={ resources.StaticPath("js/badge-toast.js") } defer></script> 569 + <script src={ resources.StaticPath("js/handle-autocomplete.js") } defer></script> 570 + <script src={ resources.StaticPath("js/vendor/jsqr.js") } defer></script> 571 + <script src={ resources.StaticPath("js/qr-scan.js") } defer></script> 572 + ``` 573 + 574 + (jsqr.js must come before qr-scan.js; both are `defer` so they execute in document order. qr-scan.js no-ops on pages without a `[data-qr-scan]` button.) 575 + 576 + - [ ] **Step 2: Regenerate templ** 577 + 578 + Run: `go tool templ generate -f features/common/layouts/base.templ` 579 + Expected: exit 0. 580 + 581 + - [ ] **Step 3: Verify the build compiles** 582 + 583 + Run: `go build ./...` 584 + Expected: exit 0, no output. 585 + 586 + - [ ] **Step 4: Commit** 587 + 588 + ```bash 589 + git add features/common/layouts/base.templ features/common/layouts/base_templ.go 590 + git commit -m "feat(qr): load jsqr + qr-scan scripts in base layout" 591 + ``` 592 + 593 + --- 594 + 595 + ## Task 7: Add the button to the home dashboard 596 + 597 + **Files:** 598 + - Modify: `features/index/pages/index.templ:132` (after the `.profile-flip-wrap` closing `</div>`) 599 + 600 + - [ ] **Step 1: Insert the shared component under the QR flip card** 601 + 602 + In `features/index/pages/index.templ`, find this region (around lines 130-133): 603 + 604 + ```templ 605 + >copy link</button> 606 + } 607 + </div> 608 + <div class="home-identity-text"> 609 + ``` 610 + 611 + and insert `@layouts.CameraScanButton()` between the `</div>` that closes `.profile-flip-wrap` and the `.home-identity-text` div, so it reads: 612 + 613 + ```templ 614 + >copy link</button> 615 + } 616 + </div> 617 + @layouts.CameraScanButton() 618 + <div class="home-identity-text"> 619 + ``` 620 + 621 + - [ ] **Step 2: Regenerate templ** 622 + 623 + Run: `go tool templ generate -f features/index/pages/index.templ` 624 + Expected: exit 0. 625 + 626 + - [ ] **Step 3: Verify the build compiles and the button renders** 627 + 628 + Run: `go build ./... && grep -c "CameraScanButton" features/index/pages/index_templ.go` 629 + Expected: build exit 0; grep prints `1`. 630 + 631 + - [ ] **Step 4: Commit** 632 + 633 + ```bash 634 + git add features/index/pages/index.templ features/index/pages/index_templ.go 635 + git commit -m "feat(qr): add scan-to-connect button to home dashboard" 636 + ``` 637 + 638 + --- 639 + 640 + ## Task 8: Final verification 641 + 642 + **Files:** none (verification only) 643 + 644 + - [ ] **Step 1: Full build + regen + JS tests are clean** 645 + 646 + Run: 647 + ```bash 648 + go build ./... && go tool templ generate && go test ./... && node --test web/resources/static/js/qr-scan.test.js && node --check web/resources/static/js/qr-scan.js 649 + ``` 650 + Expected: all succeed — Go build silent, `templ generate` clean, Go tests `ok`, the 7 jsQR tests pass, `node --check` silent. 651 + 652 + - [ ] **Step 2: Confirm the button is present on all four pages** 653 + 654 + Run: 655 + ```bash 656 + grep -l "CameraScanButton" features/profile/pages/profile.templ features/event/pages/event.templ features/events/pages/detail.templ features/index/pages/index.templ 657 + ``` 658 + Expected: all four files listed. 659 + 660 + - [ ] **Step 3: Manual device verification (mobile or emulated mobile ≤768px)** 661 + 662 + Confirm by hand (cannot be automated — camera + DOM): 663 + - The `📷 scan to connect` button appears on profile, `/e/{token}`, `/events/{token}`, and `/` (home), and is hidden on desktop widths. 664 + - Tapping opens the fullscreen overlay using the **rear** camera. 665 + - Scanning another profile's QR navigates to `/c/...` and connects. 666 + - A non-atmo.quest QR shows the "not an atmo.quest code — keep scanning" hint and keeps scanning. 667 + - Denying camera permission falls back to the photo-capture sheet; a photo of a valid QR still connects. 668 + - Cancel / tapping the backdrop closes the overlay and releases the camera (camera indicator clears).