diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 064df4e..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,54 +0,0 @@ -## What - - - -## Why - - - -## Changes - - - -### Added - -- - -### Changed - -- - -### Fixed - -- - -## Spec Compliance - - - -``` -npm run spec all -``` - -## Tests - - - -- [ ] New tests cover the changes -- [ ] All existing tests still pass -- [ ] `npm run spec all` passes (7/7) - -## Spec Updates - - - -- [ ] No spec changes needed -- [ ] Updated: - -## Session Retrospective - - - -1. **Patterns discovered:** -2. **Unexpected breakage:** -3. **What would you test differently?** diff --git a/docs/app-spec/E2E_TESTING.md b/docs/app-spec/E2E_TESTING.md index 6e6c20a..f72bd00 100644 --- a/docs/app-spec/E2E_TESTING.md +++ b/docs/app-spec/E2E_TESTING.md @@ -37,6 +37,7 @@ Export a scored IndexedDB state (from the existing test session) as JSON. Tests that need "fully scored" state inject this at test start — no waiting for scoring. The fixture contains: + - 2 individuals (1 raw, 1 imputed) - 64 trait results each with full pgsDetails/pgsBreakdown - Variant store entries @@ -56,7 +57,7 @@ data.asili.dev during CI setup. ## Test Tiers & CI Integration -### On every PR / push to dev: +### On every PR / push to dev ```yaml - name: Unit + Component tests @@ -68,7 +69,7 @@ data.asili.dev during CI setup. **~45 seconds total.** Uses pre-scored IndexedDB fixtures. No real scoring. -### Nightly (or manual dispatch): +### Nightly (or manual dispatch) ```yaml - name: E2E (full — real scoring) diff --git a/docs/app-spec/LAN_PEER_SHARING.md b/docs/app-spec/LAN_PEER_SHARING.md new file mode 100644 index 0000000..f1a78ba --- /dev/null +++ b/docs/app-spec/LAN_PEER_SHARING.md @@ -0,0 +1,466 @@ +# LAN Peer Sharing — "Family View" + +## Problem + +A user scores their DNA on their desktop browser. Results are "trapped" in that +browser's IndexedDB. They want to casually browse results on their phone (or a +family member wants to view on their device) without re-uploading and re-scoring. + +## Solution + +The source device generates a QR code containing a WebRTC offer. The viewer +scans it, generates a compressed answer displayed as a short word-code, and +the source user types that code. Peer-to-peer DataChannel opens — all data +stays on the local network. **Zero servers involved.** + +--- + +## User Flow + +### Step 1: Source enables sharing (Desktop) + +Settings → **"📡 Share Results"** section → "Start Sharing" button + +The browser: + +1. Creates a WebRTC peer connection with local ICE candidates +2. Generates an SDP offer +3. Encodes offer as a QR code displayed on screen +4. Also shows the offer as a copyable link (fallback) + +UI shows: + +``` +┌─────────────────────────────┐ +│ Scan with another device │ +│ │ +│ ┌─────────┐ │ +│ │ QR CODE │ │ +│ └─────────┘ │ +│ │ +│ Or copy link: [📋 Copy] │ +│ │ +│ Waiting for code... │ +│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │ +│ └─┘ └─┘ └─┘ └─┘ │ +│ │ +│ [Cancel] │ +└─────────────────────────────┘ +``` + +### Step 2: Viewer scans QR (Phone) + +Phone camera scans QR → opens URL like: + +``` +https://app.asili.dev/pair/ +``` + +The app: + +1. Detects the `?offer=` param +2. Decodes the SDP offer +3. Creates a peer connection, sets remote description +4. Generates an SDP answer +5. Compresses the answer into a **4-word code** (see encoding below) +6. Displays the code prominently + +UI shows: + +``` +┌─────────────────────────────┐ +│ │ +│ Your connection code: │ +│ │ +│ 🐸 FROG 🏮 LAMP │ +│ 🎲 DICE 🌊 WAVE │ +│ │ +│ Enter this on the other │ +│ device to connect. │ +│ │ +│ [Waiting for connection…] │ +└─────────────────────────────┘ +``` + +### Step 3: Source enters code (Desktop) + +User types the 4 words (or picks emoji) into the waiting input. + +The source: + +1. Decodes the word-code back into the SDP answer +2. Sets remote description +3. ICE candidates connect (both on same LAN → direct connection) +4. DataChannel opens → **paired!** + +### Step 4: Viewing + +Viewer sees source's trait grid in read-only mode. Data streams on-demand +over the DataChannel as the viewer navigates. + +--- + +## Answer Compression (SDP → 4 words) + +The full SDP answer is ~2KB, but for the "answer" side on a LAN connection +we only need: + +- ICE ufrag (4 bytes) +- ICE password (22 bytes) +- DTLS fingerprint (32 bytes) +- A candidate or two (IP:port, ~6 bytes each on LAN) + +**~70 bytes of essential data.** Encoded with a 2048-word dictionary: + +- 2048 words = 11 bits per word +- 4 words = 44 bits = not enough + +Actually let's be more careful. Minimum viable answer payload: + +- ICE ufrag: 4 chars (24 bits) +- ICE pwd: 22 chars (132 bits) +- Fingerprint: 32 bytes (256 bits) +- 1 LAN candidate IP:port (48 bits) +- Total: ~460 bits → ~42 bytes + +With a 4096-word list (12 bits/word): 460 / 12 = **39 words**. Too many. + +### Revised approach: Shared-secret derivation + +Instead of encoding the full answer, use a **PAKE-style key exchange**: + +1. Source generates a random 4-word code (44 bits of entropy from 2048-word list) +2. Source embeds a hash of this code in the QR/offer URL +3. Viewer decodes the offer, sees the hash, generates its answer +4. Viewer encrypts the answer with the 4-word code and stores it in + the WebRTC DataChannel negotiation (or more practically...) + +**Actually, simplest approach:** + +### Revised approach: Offer contains everything, answer is minimal + +Since both devices are on the same LAN: + +1. The **offer** QR contains the source's full SDP + local IP candidates +2. The viewer connects directly to the source's ICE candidates +3. The "answer" the viewer needs to send back is just the DTLS fingerprint + for mutual authentication + +DTLS fingerprint = 32 bytes = 256 bits. +With a 2048-word list: 256 / 11 = **24 words**. Still too many. + +### Final approach: Relay answer through the offer's DataChannel + +Wait — actually the cleanest approach: + +1. Source creates offer with all local ICE candidates baked in +2. QR contains the offer +3. Viewer creates answer, but instead of encoding it into words... +4. **The viewer attempts ICE connectivity directly** — on a LAN, the + viewer already knows the source's IP:port from the offer candidates +5. The viewer opens a **temporary HTTP endpoint?** — no, browsers can't. + +### ACTUAL final approach: Two QR codes + +Simplest, most honest implementation: + +``` +SOURCE VIEWER +────── ────── +1. Show QR (offer) + 2. Scan → decode offer + Generate answer + Show QR (answer) +3. Scan viewer's QR (via webcam) + OR viewer shows short URL that + source opens in new tab on same machine + → Decode answer + → Connect! +``` + +For the "scan back" step, options by usability: + +**Option A: Webcam scan (best UX if user has webcam)** +Desktop activates camera, points at phone screen, reads QR. + +**Option B: Manual code (universal fallback)** +The answer IS too long for 4 words, but we can use a **6-digit PIN** as a +session ID + a local HTTP trick: + +Actually, let me reconsider. The real problem is getting ~2KB from phone → desktop +without a server. The user's options in practice: + +1. **Show QR on phone → desktop webcam scans it** (needs camera permission) +2. **Phone copies link → user pastes into desktop** (airdrop/messages/email to self) +3. **Phone shows numeric code → desktop types it** (only works if code is short) + +For #3 to work, we need the code to be SHORT, which means we need a rendezvous. + +### THE ACTUAL SOLUTION: Use the offer URL as a rendezvous + +Here's the trick that makes this work with one QR + one short code: + +1. Source generates a **random 6-digit room code** (e.g. `847293`) +2. Source creates WebRTC offer +3. Source starts **polling its own open tab** via BroadcastChannel for the answer +4. QR encodes: `https://app.asili.dev/beta?pair=847293` +5. Viewer opens URL, sees "Enter the code shown on the other device" + +Wait no, BroadcastChannel is same-device only... + +--- + +## ✅ FINAL DESIGN: QR + Webcam/Paste + +After working through all the constraints, the cleanest zero-server flow: + +### Primary: QR → QR (webcam) + +``` +SOURCE (desktop) VIEWER (phone) +1. Click "Share" → + Generate offer → + Show QR on screen + 2. Scan QR with phone camera + → Decode offer + → Generate answer + → Show answer as QR on phone + +3. Click "Scan response" → + Desktop webcam activates → + Point at phone QR → + Decode answer → + Connected! ✅ +``` + +### Fallback: QR → Paste + +``` +SOURCE (desktop) VIEWER (phone) +1. Click "Share" → + Show QR on screen + 2. Scan QR with phone camera + → Generate answer + → Show "Copy Code" button + (copies encoded answer to clipboard) + +3. User sends code to desktop + (paste in browser, iMessage, etc.) + Source has "Paste response" input → + Decode → Connected! ✅ +``` + +### The encoded answer + +The answer blob (~2KB of SDP) gets: + +1. Stripped to essential fields only (ufrag, pwd, fingerprint, candidates) +2. Binary packed (~80 bytes) +3. Base64url encoded (~107 chars) + +**107 characters** is copyable/pasteable. Not pretty, but functional. +Displayed on the phone as a monospace block with a big "Copy" button. + +--- + +## Architecture + +``` +┌──────────────────────┐ ┌──────────────────────┐ +│ SOURCE (Desktop) │ │ VIEWER (Phone) │ +│ │ WebRTC │ │ +│ IndexedDB ─────────────DataChannel──── Read-only UI │ +│ │ (direct) │ │ +│ 1. Show QR (offer) │ │ 2. Scan QR │ +│ 3. Scan/paste answer│ │ Show answer QR │ +│ │ │ │ +└──────────────────────┘ └──────────────────────┘ + + No server. No relay. No cloud. Direct LAN connection. +``` + +### Data Protocol (over DataChannel) + +Once connected, viewer requests data as needed: + +```js +// Viewer sends: +{ type: "get-individuals" } +{ type: "get-results", individualId: "..." } +{ type: "get-trait-detail", individualId: "...", traitId: "..." } + +// Source responds: +{ type: "individuals", data: [...] } +{ type: "results", data: [...] } +{ type: "trait-detail", data: {...} } +``` + +--- + +## SDP Compression + +Full SDP offer/answer is ~2-4KB of text. For QR codes, we compress: + +### Offer (source → QR) + +QR codes can hold ~4KB in binary mode. A full SDP offer fits, but barely. +Better to extract only what's needed: + +```js +// Minimal offer payload (~200 bytes binary) +{ + ufrag: "a1b2", // 4 bytes + pwd: "aGVsbG8gd29ybGQ...", // 22 bytes + fingerprint: Uint8Array(32), + candidates: [ + { ip: "192.168.1.42", port: 54321 }, // ~6 bytes each + { ip: "192.168.1.42", port: 54322 }, + ], + // Enough to reconstruct a valid SDP +} +``` + +Compressed + base64: ~300 chars → clean QR code. + +### Answer (viewer → QR or paste) + +Same structure, typically 1 candidate (phone's LAN IP): +~150 bytes binary → ~200 chars base64 → small QR or pasteable string. + +--- + +## Component Structure + +``` +src/ +├── components/molecules/ +│ ├── share-source/ +│ │ └── share-source.js # QR display + webcam scanner + paste input +│ ├── share-viewer/ +│ │ └── share-viewer.js # Answer QR display + "copy code" button +│ └── viewer-bar/ +│ └── viewer-bar.js # "Viewing X's results" persistent banner +├── utils/ +│ ├── peer-rtc.js # WebRTC offer/answer/DataChannel +│ ├── peer-protocol.js # Request/response over DataChannel +│ ├── peer-sdp.js # SDP compression/decompression +│ └── peer-qr.js # QR encode/decode (uses existing lib or canvas) +``` + +--- + +## Settings Drawer Integration + +New section in Settings: + +``` +┌─────────────────────────────────────┐ +│ 📡 Share Results │ +│ │ +│ Let another device on your network │ +│ view your scored results. │ +│ │ +│ [Start Sharing] │ +│ │ +│ • No data leaves your network │ +│ • Connection is direct, device │ +│ to device │ +│ • Closes when you close this tab │ +└─────────────────────────────────────┘ +``` + +When sharing is active: + +``` +┌─────────────────────────────────────┐ +│ 📡 Share Results │ +│ │ +│ 🟢 Sharing active │ +│ 👀 1 device connected │ +│ │ +│ [Stop Sharing] │ +└─────────────────────────────────────┘ +``` + +--- + +## Viewer Mode + +When a device connects as a viewer, the app enters **read-only mode**: + +- Upload zone hidden +- Scoring controls hidden +- Settings shows "Connected to [Source]" instead of share controls +- Persistent banner at top: "📡 Viewing results from Desktop Chrome" +- Individual switcher shows source's individuals +- Trait grid populated via DataChannel requests (lazy-loaded) +- Disconnect button in banner + +--- + +## Screen Timeout / Keep-Alive + +Source device must stay awake while sharing: + +- Extend existing `wake-lock.js` to activate during active sharing session +- If connection drops (screen locked briefly), auto-reconnect via ICE restart +- UI tip: "💡 Keep this browser tab open to share results" + +--- + +## Security + +| Property | How | +|---|---| +| Physical proximity required | Must scan QR from the device's screen | +| No cloud involvement | WebRTC direct, no relay/TURN/signaling server | +| Session-only | Closing either tab ends connection | +| Read-only | Viewer cannot write to source IndexedDB | +| LAN-scoped | ICE candidates are local IPs only (no TURN = no internet relay) | +| Authenticated | DTLS fingerprint in offer/answer = MITM-proof once paired | + +--- + +## Implementation Phases + +### Phase 1: SDP compression + QR + +- `peer-sdp.js` — extract/reconstruct minimal SDP +- `peer-qr.js` — QR generation (canvas-based, no dependency) +- Test roundtrip: compress → QR → decode → valid SDP + +### Phase 2: WebRTC + DataChannel + +- `peer-rtc.js` — offer/answer flow, DataChannel setup +- `peer-protocol.js` — request/response over DataChannel +- Test on two tabs same machine first, then cross-device LAN + +### Phase 3: Source UI + +- `share-source.js` — QR display, webcam scanner, paste fallback +- Settings drawer integration +- Wake lock extension + +### Phase 4: Viewer UI + +- `share-viewer.js` — answer display (QR + copy button) +- Viewer mode (read-only trait grid) +- `viewer-bar.js` — connection status banner + +--- + +## Open Questions + +1. **QR library** — Generate with canvas (no dep) or bundle a tiny lib + like `qr-creator` (~4KB)? Recommend canvas for zero-dep consistency. + +2. **Webcam scanning** — Use `BarcodeDetector` API (Chrome/Edge native, + no library needed) with fallback to `jsQR` for Firefox/Safari? + +3. **Multiple viewers** — Allow multiple phones to scan the same QR? + The offer would need to support multiple answers (multiple peer + connections). Recommend: one QR per viewer, source can click + "Add another device" to generate a fresh offer. + +4. **Timeout** — Auto-stop sharing after N minutes of inactivity? + Recommend 30 min idle timeout with "extend" prompt. diff --git a/package.json b/package.json index 4744fec..4f9f40b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "@duckdb/duckdb-wasm": "1.33.1-dev44.0", "express": "^5.2.1", "hybrids": "^9.1.22", - "lucide-static": "^1.7.0" + "jsqr": "1.4.0", + "lucide-static": "^1.7.0", + "uqr": "0.1.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5568d1f..b75e25b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: hybrids: specifier: ^9.1.22 version: 9.1.22 + jsqr: + specifier: 1.4.0 + version: 1.4.0 lucide-static: specifier: ^1.7.0 version: 1.7.0 + uqr: + specifier: 0.1.3 + version: 0.1.3 devDependencies: '@open-wc/testing': specifier: ^4.0.0 @@ -1815,6 +1821,9 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + katex@0.16.44: resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} hasBin: true @@ -2708,6 +2717,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4607,6 +4619,8 @@ snapshots: jsonpointer@5.0.1: {} + jsqr@1.4.0: {} + katex@0.16.44: dependencies: commander: 8.3.0 @@ -5737,6 +5751,8 @@ snapshots: unpipe@1.0.0: {} + uqr@0.1.3: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/scripts/test.js b/scripts/test.js index bf826d6..5a6dc24 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -17,7 +17,7 @@ * @module scripts/test */ -import { execSync, spawnSync } from 'node:child_process'; +import { execSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readdirSync, existsSync } from 'node:fs'; @@ -41,39 +41,64 @@ function findTests(dir) { return results; } +/** + * + */ function runNode() { const componentDir = resolve(ROOT, 'src/components'); const allTests = findTests(resolve(ROOT, 'src')).concat(findTests(resolve(ROOT, 'packages'))); const nodeTests = allTests.filter((f) => !f.startsWith(componentDir)); - if (!nodeTests.length) { console.log('No node tests found.'); return true; } + if (!nodeTests.length) { + console.log('No node tests found.'); + return true; + } console.log(`\n ▶ Node tests (${nodeTests.length} files)\n`); try { execSync(`node --test ${nodeTests.join(' ')} ${extra}`, { cwd: ROOT, stdio: 'inherit' }); return true; - } catch { return false; } + } catch { + return false; + } } +/** + * + */ function runBrowser() { const componentDir = resolve(ROOT, 'src/components'); const browserTests = findTests(componentDir); - if (!browserTests.length) { console.log('No browser tests found.'); return true; } + if (!browserTests.length) { + console.log('No browser tests found.'); + return true; + } console.log(`\n ▶ Browser tests (${browserTests.length} files)\n`); try { execSync(`npx web-test-runner --config .configs/web-test-runner.config.js ${extra}`, { - cwd: ROOT, stdio: 'inherit', + cwd: ROOT, + stdio: 'inherit', }); return true; - } catch { return false; } + } catch { + return false; + } } +/** + * + */ function runE2E() { console.log(`\n ▶ E2E tests (Playwright)\n`); try { execSync(`npx playwright test ${extra}`, { cwd: ROOT, stdio: 'inherit' }); return true; - } catch { return false; } + } catch { + return false; + } } +/** + * + */ async function prompt() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log('\n Which tests to run?\n'); @@ -81,13 +106,25 @@ async function prompt() { console.log(' 2) browser — component tests'); console.log(' 3) e2e — end-to-end (Playwright)'); console.log(' 4) all — everything\n'); - const answer = await new Promise(r => rl.question(' Choice [4]: ', r)); + const answer = await new Promise((r) => rl.question(' Choice [4]: ', r)); rl.close(); const choice = answer.trim() || '4'; - const map = { '1': 'node', '2': 'browser', '3': 'e2e', '4': 'all', node: 'node', browser: 'browser', e2e: 'e2e', all: 'all' }; + const map = { + 1: 'node', + 2: 'browser', + 3: 'e2e', + 4: 'all', + node: 'node', + browser: 'browser', + e2e: 'e2e', + all: 'all', + }; return map[choice] || 'all'; } +/** + * + */ async function main() { let cmd = command; @@ -98,9 +135,15 @@ async function main() { } let passed = true; - if (cmd === 'node' || cmd === 'all') { if (!runNode()) passed = false; } - if (cmd === 'browser' || cmd === 'all') { if (!runBrowser()) passed = false; } - if (cmd === 'e2e' || cmd === 'all') { if (!runE2E()) passed = false; } + if (cmd === 'node' || cmd === 'all') { + if (!runNode()) passed = false; + } + if (cmd === 'browser' || cmd === 'all') { + if (!runBrowser()) passed = false; + } + if (cmd === 'e2e' || cmd === 'all') { + if (!runE2E()) passed = false; + } if (!['node', 'browser', 'e2e', 'all'].includes(cmd)) { console.error(`Unknown test command: ${cmd}`); diff --git a/scripts/vendor-deps.js b/scripts/vendor-deps.js index 1a85bfb..76e4fa5 100644 --- a/scripts/vendor-deps.js +++ b/scripts/vendor-deps.js @@ -13,15 +13,34 @@ import { fileURLToPath } from 'node:url'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const VENDOR_DIR = resolve(ROOT, 'src/vendor'); -/** @type {{ name: string, src: string }[]} */ -const DEPS = [{ name: 'hybrids', src: 'node_modules/hybrids/src' }]; +/** @type {{ name: string, src: string, file?: string, esm?: boolean }[]} */ +const DEPS = [ + { name: 'hybrids', src: 'node_modules/hybrids/src' }, + { name: 'uqr', src: 'node_modules/uqr/dist', file: 'index.mjs' }, + { name: 'jsqr', src: 'node_modules/jsqr/dist', file: 'jsQR.js', esm: true }, +]; mkdirSync(VENDOR_DIR, { recursive: true }); for (const dep of DEPS) { - const src = resolve(ROOT, dep.src); - const dest = resolve(VENDOR_DIR, dep.name); - cpSync(src, dest, { recursive: true }); + if (dep.file) { + const src = resolve(ROOT, dep.src, dep.file); + const destDir = resolve(VENDOR_DIR, dep.name); + mkdirSync(destDir, { recursive: true }); + if (dep.esm) { + // Wrap UMD/CJS module as ESM + let content = readFileSync(src, 'utf8'); + const destFile = dep.file.replace(/\.js$/, '.mjs'); + content = `var module = { exports: {} }; var exports = module.exports;\n${content}\nexport default module.exports;\n`; + writeFileSync(resolve(destDir, destFile), content); + } else { + cpSync(src, resolve(destDir, dep.file)); + } + } else { + const src = resolve(ROOT, dep.src); + const dest = resolve(VENDOR_DIR, dep.name); + cpSync(src, dest, { recursive: true }); + } console.log(`✓ Vendored: ${dep.name} → src/vendor/${dep.name}/`); } diff --git a/src/components/molecules/share-source/share-source-handlers.js b/src/components/molecules/share-source/share-source-handlers.js new file mode 100644 index 0000000..8141718 --- /dev/null +++ b/src/components/molecules/share-source/share-source-handlers.js @@ -0,0 +1,107 @@ +/** + * Share source handlers — offer generation, scanning, answer submission. + * @module components/molecules/share-source/share-source-handlers + */ + +import { createOffer, acceptAnswer, close, onOpen, onData, viewerCount } from '#utils/peer-rtc.js'; +import { startServing } from '#utils/peer-protocol.js'; +import { generate as generateQR, scan as scanQR } from '#utils/peer-qr.js'; +import { acquire, release } from '#utils/wake-lock.js'; +import { pause as pauseScoring, resume as resumeScoring } from '#utils/scoring-queue.js'; + +/** Whether startServing has been called this session. */ +let serving = false; + +/** @param {object} host */ +export async function generateNewOffer(host) { + const offer = await createOffer(); + const url = `${location.origin}/pair/${offer}`; + host.offerText = url; + host.qrSvg = ''; + host.answerInput = ''; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + host.qrSvg = generateQR(url); +} + +/** @param {object} host */ +export async function startSharing(host) { + try { + host.state = 'loading'; + await pauseScoring(); + await generateNewOffer(host); + host.state = 'offering'; + if (!serving) { + serving = true; + startServing(); + } + await acquire(); + onOpen(() => { + host.viewers = viewerCount(); + }); + onData((msg) => { + if (msg.type === '_disconnected') { + host.viewers = viewerCount(); + if (host.viewers === 0) release(); + } + }); + } catch (e) { + host.error = e.message; + host.state = 'idle'; + } +} + +/** @param {object} host */ +export async function submitAnswer(host) { + try { + host.error = ''; + if (viewerCount() > 0) return; + await acceptAnswer(host.answerInput.trim()); + } catch (e) { + host.error = 'Invalid code — try again'; + } +} + +/** @param {object} host */ +export function copyLink(host) { + navigator.clipboard.writeText(host.offerText); +} + +/** @param {object} host */ +export async function startScan(host) { + host.state = 'scanning'; + host.error = ''; + await new Promise((r) => requestAnimationFrame(r)); + const video = /** @type {HTMLVideoElement} */ (document.getElementById('share-scan-video')); + try { + const code = await scanQR(video); + host.answerInput = code; + host.state = 'offering'; + await submitAnswer(host); + } catch (e) { + host.error = e.message; + host.state = 'offering'; + } +} + +/** @param {object} host */ +export function cancelScan(host) { + const video = /** @type {HTMLVideoElement} */ (document.getElementById('share-scan-video')); + if (video?.srcObject) { + /** @type {MediaStream} */ (video.srcObject).getTracks().forEach((t) => t.stop()); + video.srcObject = null; + } + host.state = 'offering'; +} + +/** @param {object} host */ +export function stopSharing(host) { + close(); + release(); + resumeScoring(); + serving = false; + host.state = 'idle'; + host.qrSvg = ''; + host.offerText = ''; + host.answerInput = ''; + host.viewers = 0; +} diff --git a/src/components/molecules/share-source/share-source-scan.css b/src/components/molecules/share-source/share-source-scan.css new file mode 100644 index 0000000..9fb5a7b --- /dev/null +++ b/src/components/molecules/share-source/share-source-scan.css @@ -0,0 +1,57 @@ +/* share-source scanner — webcam overlay and reticle */ + +.share-source__scan-container { + position: relative; + width: 100%; + margin: 0.75rem 0; + border-radius: var(--radius); + overflow: hidden; +} + +.share-source__video { + display: block; + width: 100%; + background: #000; + aspect-ratio: 4/3; + object-fit: cover; +} + +.share-source__scan-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} + +.share-source__scan-reticle { + width: 60%; + aspect-ratio: 1; + border: 2px solid rgb(255 255 255 / 70%); + border-radius: 12px; + box-shadow: 0 0 0 9999px rgb(0 0 0 / 30%); + animation: share-reticle-pulse 2s ease-in-out infinite; +} + +@keyframes share-reticle-pulse { + 0%, + 100% { + border-color: rgb(255 255 255 / 50%); + } + + 50% { + border-color: rgb(255 255 255 / 90%); + } +} + +.share-source__scan-hint { + position: absolute; + bottom: 0.75rem; + left: 0; + right: 0; + text-align: center; + font-size: var(--text-xs); + color: rgb(255 255 255 / 80%); + text-shadow: 0 1px 3px rgb(0 0 0 / 80%); + pointer-events: none; +} diff --git a/src/components/molecules/share-source/share-source.css b/src/components/molecules/share-source/share-source.css new file mode 100644 index 0000000..dad843d --- /dev/null +++ b/src/components/molecules/share-source/share-source.css @@ -0,0 +1,107 @@ +/* share-source — QR offer + answer input in settings drawer */ + +.share-source__qr { + width: 100%; + margin: 0.75rem 0; +} + +.share-source__qr svg { + width: 100%; + height: auto; + border-radius: var(--radius); +} + +.share-source__qr-placeholder { + width: 100%; + aspect-ratio: 1; + border-radius: var(--radius); + background: var(--surface-alt, #1a1a2e); + display: grid; + place-items: center; + margin: 0.75rem 0; +} + +.share-source__pulse { + width: 3rem; + height: 3rem; + border-radius: 50%; + background: var(--border); + animation: share-src-pulse 1.5s ease-in-out infinite; +} + +@keyframes share-src-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.85); + } + + 50% { + opacity: 0.7; + transform: scale(1); + } +} + +.share-source__link { + text-align: center; + margin-bottom: 1rem; +} + +.share-source__status { + font-size: var(--text-sm); + color: var(--text-muted); + margin: 0.5rem 0; +} + +.share-source__input { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + +.share-source__input input { + flex: 1; + font-family: monospace; + font-size: var(--text-sm); +} + +.share-source__actions { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + +.share-source__loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem 0; + color: var(--text-muted); + font-size: var(--text-sm); +} + +.share-source__spinner { + width: 1.5rem; + height: 1.5rem; + border: 3px solid var(--border); + border-top-color: var(--accent, #4dabf7); + border-radius: 50%; + animation: share-src-spin 0.8s linear infinite; +} + +@keyframes share-src-spin { + to { + transform: rotate(360deg); + } +} + +.share-source__error { + color: var(--red); + font-size: var(--text-sm); +} + +.share-source__connected { + font-weight: 600; + color: var(--green); +} diff --git a/src/components/molecules/share-source/share-source.js b/src/components/molecules/share-source/share-source.js new file mode 100644 index 0000000..c0f35c9 --- /dev/null +++ b/src/components/molecules/share-source/share-source.js @@ -0,0 +1,101 @@ +/** + * Share source — generates QR offer and accepts answer to establish connection. + * Keeps QR visible after first connection for additional viewers / reconnection. + * @module components/molecules/share-source + */ + +import { html, define } from 'hybrids'; +import { + startSharing, + submitAnswer, + copyLink, + startScan, + cancelScan, + stopSharing, +} from './share-source-handlers.js'; + +export default define({ + tag: 'share-source', + state: 'idle', // idle | loading | offering | scanning + qrSvg: '', + offerText: '', + answerInput: '', + error: '', + viewers: 0, + render: { + value: ({ state, qrSvg, offerText, answerInput, error, viewers }) => { + if (state === 'idle') { + return html` +
+ +
+ `; + } + if (state === 'loading') { + return html` +
+ +
+ `; + } + if (state === 'scanning') { + return html` +
+ + + ${error ? html`` : ''} + +
+ `; + } + if (state === 'offering') { + return html` +
+ ${viewers > 0 + ? html`` + : ''} + + ${qrSvg + ? html`` + : html``} + + + + + ${error ? html`` : ''} + +
+ `; + } + return html``; + }, + shadow: false, + }, +}); diff --git a/src/components/molecules/share-viewer/share-viewer-handlers.js b/src/components/molecules/share-viewer/share-viewer-handlers.js new file mode 100644 index 0000000..153eecb --- /dev/null +++ b/src/components/molecules/share-viewer/share-viewer-handlers.js @@ -0,0 +1,83 @@ +/** + * Share viewer handlers — offer acceptance, navigation, QR generation. + * @module components/molecules/share-viewer/share-viewer-handlers + */ + +import { acceptOffer, isConnected, onOpen } from '#utils/peer-rtc.js'; +import { startViewing } from '#utils/peer-protocol.js'; +import { enterViewerMode } from '#utils/peer-state.js'; +import { generate as generateQR } from '#utils/peer-qr.js'; + +/** Module-level answer storage — survives component re-mounts. */ +let savedAnswer = ''; +let generating = false; + +/** @param {object} host */ +export async function generateAnswer(host) { + if (generating) return; + + if (isConnected()) { + navigateToResults(); + return; + } + + if (savedAnswer) { + host.answerCode = savedAnswer; + host.state = 'waiting'; + await nextPaint(); + host.answerQr = generateQR(savedAnswer); + onOpen(() => navigateToResults()); + return; + } + + generating = true; + try { + host.state = 'generating'; + host.error = ''; + const answer = await acceptOffer(host.offer); + savedAnswer = answer; + host.answerCode = answer; + host.state = 'waiting'; + await nextPaint(); + host.answerQr = generateQR(answer); + startViewing(); + onOpen(() => { + host.state = 'connected'; + enterViewerMode(); + setTimeout(() => navigateToResults(), 800); + }); + } catch (e) { + savedAnswer = ''; + host.state = 'error'; + host.error = 'Connection setup failed. Scan a fresh QR code from the source device.'; + } finally { + generating = false; + } +} + +/** + * + */ +export function navigateToResults() { + window.history.pushState(null, '', '/beta'); + window.dispatchEvent(new PopStateEvent('popstate')); +} + +/** @param {object} host */ +export function copyCode(host) { + navigator.clipboard.writeText(host.answerCode); +} + +/** @param {object} host */ +export function retry(host) { + host.state = 'generating'; + host.error = ''; + savedAnswer = ''; + generating = false; + generateAnswer(host); +} + +/** Wait for the browser to actually paint before continuing. */ +function nextPaint() { + return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); +} diff --git a/src/components/molecules/share-viewer/share-viewer.css b/src/components/molecules/share-viewer/share-viewer.css new file mode 100644 index 0000000..91fb731 --- /dev/null +++ b/src/components/molecules/share-viewer/share-viewer.css @@ -0,0 +1,109 @@ +/* share-viewer — answer QR + code display for pairing */ + +.share-viewer { + text-align: center; + padding: 2rem 1rem; +} + +.share-viewer__loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 3rem 0; + color: var(--text-muted); +} + +.share-viewer__spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--border); + border-top-color: var(--accent, #4dabf7); + border-radius: 50%; + animation: share-spin 0.8s linear infinite; +} + +@keyframes share-spin { + to { + transform: rotate(360deg); + } +} + +.share-viewer__label { + font-size: var(--text-sm); + color: var(--text-muted); + margin: 0.75rem 0 0.25rem; +} + +.share-viewer__qr { + width: 100%; + max-width: 100%; + margin: 0.75rem 0; +} + +.share-viewer__qr svg { + width: 100%; + height: auto; + border-radius: var(--radius); +} + +.share-viewer__qr-placeholder { + width: 100%; + aspect-ratio: 1; + border-radius: var(--radius); + background: var(--surface-alt, #1a1a2e); + display: grid; + place-items: center; + margin: 0.75rem 0; +} + +.share-viewer__pulse { + width: 3rem; + height: 3rem; + border-radius: 50%; + background: var(--border); + animation: share-view-pulse 1.5s ease-in-out infinite; +} + +@keyframes share-view-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.85); + } + + 50% { + opacity: 0.7; + transform: scale(1); + } +} + +.share-viewer__code { + font-family: monospace; + font-size: var(--text-xs); + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + word-break: break-all; + margin: 0.75rem 0; + user-select: all; + text-align: left; +} + +.share-viewer__hint { + font-size: var(--text-xs); + color: var(--text-muted); + margin-top: 0.75rem; +} + +.share-viewer__connected { + font-size: 1.25rem; + font-weight: 600; + color: var(--green); + margin-bottom: 1rem; +} + +.share-viewer__error { + color: var(--red); +} diff --git a/src/components/molecules/share-viewer/share-viewer.js b/src/components/molecules/share-viewer/share-viewer.js new file mode 100644 index 0000000..925138d --- /dev/null +++ b/src/components/molecules/share-viewer/share-viewer.js @@ -0,0 +1,90 @@ +/** + * Share viewer — accepts an offer from route param, generates answer code for pairing. + * Rendered inside pair-view when user scans QR and lands on /pair/:offer. + * @module components/molecules/share-viewer + */ + +import { html, define } from 'hybrids'; +import { isConnected } from '#utils/peer-rtc.js'; +import { generateAnswer, copyCode, retry, navigateToResults } from './share-viewer-handlers.js'; + +export default define({ + tag: 'share-viewer', + offer: { + value: '', + connect(host) { + requestAnimationFrame(() => { + if (host.offer && host.state === 'generating') generateAnswer(host); + }); + }, + observe(host, val) { + if (val && host.state === 'generating') generateAnswer(host); + }, + }, + answerCode: '', + answerQr: '', + state: { + value: 'generating', // generating | waiting | connected | error + connect(host) { + if (isConnected()) { + host.state = 'connected'; + return; + } + const onVis = () => { + if (document.visibilityState === 'visible' && isConnected() && host.state === 'waiting') { + host.state = 'connected'; + navigateToResults(); + } + }; + document.addEventListener('visibilitychange', onVis); + return () => document.removeEventListener('visibilitychange', onVis); + }, + }, + error: '', + render: { + value: (host) => { + const { state, answerCode, answerQr, error } = host; + if (state === 'generating') { + return html` +
+ +
+ `; + } + if (state === 'waiting') { + return html` +
+ + ${answerQr + ? html`` + : html``} + + + + +
+ `; + } + if (state === 'connected') { + return html` +
+ + View Results → +
+ `; + } + return html` +
+ + +
+ `; + }, + shadow: false, + }, +}); diff --git a/src/components/organisms/settings-drawer/drawer-sections.js b/src/components/organisms/settings-drawer/drawer-sections.js index 232815d..ab71153 100644 --- a/src/components/organisms/settings-drawer/drawer-sections.js +++ b/src/components/organisms/settings-drawer/drawer-sections.js @@ -17,9 +17,26 @@ import { handleSystemDiagnostic, } from './drawer-handlers.js'; import '#molecules/accordion-panel/accordion-panel.js'; +import '#molecules/share-source/share-source.js'; export { dangerSection, footerSection } from './drawer-danger.js'; +/** + * Share results section — QR-based LAN peer sharing. + */ +export function nearbySection() { + return html` +
+

Share Results

+ +

+ + Share with another device on your network. Direct connection — no data leaves your devices. +

+
+ `; +} + /** * */ diff --git a/src/components/organisms/settings-drawer/settings-drawer.js b/src/components/organisms/settings-drawer/settings-drawer.js index f38294e..bcba38a 100644 --- a/src/components/organisms/settings-drawer/settings-drawer.js +++ b/src/components/organisms/settings-drawer/settings-drawer.js @@ -11,6 +11,7 @@ import { individualsSection, storageSection, scoringSection, + nearbySection, developerSection, dangerSection, footerSection, @@ -58,7 +59,7 @@ export default define({
${individualsSection(host)} ${storageSection(host)} ${scoringSection(host)} - ${developerSection(host)} ${dangerSection(host)} ${footerSection()} + ${nearbySection()} ${developerSection(host)} ${dangerSection(host)} ${footerSection()}
diff --git a/src/components/organisms/trait-grid/render-card.js b/src/components/organisms/trait-grid/render-card.js index d3f7ca3..b87062f 100644 --- a/src/components/organisms/trait-grid/render-card.js +++ b/src/components/organisms/trait-grid/render-card.js @@ -6,6 +6,7 @@ import { html, router } from 'hybrids'; import { results, getActiveId } from '#pages/beta/results-store.js'; import * as idb from '/packages/core/src/data-layer/idb.js'; +import { isViewing, getIndividuals as getRemoteIndividuals } from '#utils/peer-state.js'; import { formatTraitValue } from '/packages/core/src/formatter.js'; import { traitCategory } from './helpers.js'; import TraitDetailView from '#pages/trait-detail/trait-detail-view.js'; @@ -81,9 +82,14 @@ export function renderCard(t, rc, scoring) { /** Load active individual's emoji. */ export async function loadActiveEmoji() { try { - await idb.openDB(); const id = getActiveId(); - const individuals = await idb.getAll('individuals'); + let individuals; + if (isViewing()) { + individuals = await getRemoteIndividuals(); + } else { + await idb.openDB(); + individuals = await idb.getAll('individuals'); + } const active = individuals.find((i) => i.id === id); if (active) activeEmoji = active.emoji || '\u{1F464}'; } catch { diff --git a/src/index.html b/src/index.html index 6ec2d77..2ed883c 100644 --- a/src/index.html +++ b/src/index.html @@ -75,11 +75,16 @@ + + +