eleven / ocho Public

QR codes for joining a channel in person (#3)

* web: add client-side QR code encoder

A self-contained, dependency-free QR encoder (vanilla ES module, byte/UTF-8
mode, versions 1–40, all ECC levels with smallest-fit selection, Reed–Solomon,
all 8 masks with penalty scoring). Pure qrMatrix() plus a browser qrCanvas()
helper.

Generating QR codes client-side is required by the E2E model: an invite QR
encodes the full join link including the #k= key fragment, so the key must
never reach a server or third-party QR API. Verified by decoding its own
output with an independent decoder across L/M/Q/H up to version 40.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* admin: show a QR code for each invite link

Adds a "QR" action per invite that opens a modal with a scannable QR of the
join link, for joining in person. The QR is rendered in the browser from the
same link the "Copy link" button uses (key included), and the modal warns that
it's as sensitive as the secret link. Revoking the invite still kills it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* admin: show the invite QR full-screen

The QR now opens as an opaque full-screen takeover instead of a translucent
modal, so the rest of the admin console isn't visible behind it — cleaner to
scan, and nothing else (keys, links, other invites) is on screen. Close with
Done, Escape, or tapping outside the card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam d46bf0389832a0d5e9cdccd3c53e36221fddcf1b parent 22ec2fd
3 files changed, +592 −0
  • web/static/admin.js +69 −0
  • web/static/qrcode.js +494 −0
  • web/static/style.css +29 −0
diff --git a/web/static/admin.js b/web/static/admin.js
index bb9cc5f..87c7ae5 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -1,4 +1,5 @@
import { generateKey, importKey } from "/static/crypto.js";
+import { qrCanvas } from "/static/qrcode.js";
const ADMIN_KEY = "lchat_admin_token";
const GROUP_KEY = "lchat_group_key";
@@ -216,6 +217,12 @@ function renderInvites(invites) {
copyBtn.onclick = () => copy(linkFor(inv.token), "Invite link copied");
row.appendChild(copyBtn);
+ const qrBtn = document.createElement("button");
+ qrBtn.className = "secondary";
+ qrBtn.textContent = "QR";
+ qrBtn.onclick = () => showQRModal(inv);
+ row.appendChild(qrBtn);
+
const revokeBtn = document.createElement("button");
revokeBtn.className = "secondary";
revokeBtn.textContent = "Revoke";
@@ -230,6 +237,68 @@ function renderInvites(invites) {
}
}
+// Show a scannable QR of an invite link, for joining in person. The QR encodes
+// the FULL join link — including the #k= key fragment — so it's rendered here in
+// the browser (never server-side, which would leak the key) and must be treated
+// like the secret link: anyone who scans it can read the whole chat.
+function showQRModal(inv) {
+ const link = linkFor(inv.token);
+
+ // Full-screen, opaque takeover — hides the rest of the admin console so only
+ // the QR is on screen (easier to scan, nothing sensitive shown behind it).
+ const backdrop = document.createElement("div");
+ backdrop.className = "qr-screen";
+ const card = document.createElement("div");
+ card.className = "card qr-modal";
+
+ const h = document.createElement("h2");
+ h.textContent = inv.label ? `Invite: ${inv.label}` : "Invite QR";
+ card.appendChild(h);
+
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent =
+ "Scan to join. Anyone who scans it can read the whole chat, so treat it like the secret link.";
+ card.appendChild(note);
+
+ const wrap = document.createElement("div");
+ wrap.className = "qr-wrap";
+ try {
+ wrap.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
+ } catch {
+ wrap.textContent = "This link is too long to show as a QR code.";
+ }
+ card.appendChild(wrap);
+
+ const close = () => {
+ backdrop.remove();
+ document.removeEventListener("keydown", onKey);
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+
+ const row = document.createElement("div");
+ row.className = "row";
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "secondary";
+ copyBtn.textContent = "Copy link";
+ copyBtn.onclick = () => copy(link, "Invite link copied");
+ const doneBtn = document.createElement("button");
+ doneBtn.textContent = "Done";
+ doneBtn.onclick = close;
+ row.appendChild(copyBtn);
+ row.appendChild(doneBtn);
+ card.appendChild(row);
+
+ backdrop.appendChild(card);
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) close();
+ });
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(backdrop);
+}
+
// ---- helpers ------------------------------------------------------------
function api(method, path, body) {
diff --git a/web/static/qrcode.js b/web/static/qrcode.js
new file mode 100644
index 0000000..d0b2fe9
--- /dev/null
+++ b/web/static/qrcode.js
@@ -0,0 +1,494 @@
+// Self-contained QR Code (model 2) encoder — byte mode, versions 1-40, all four
+// ECC levels. We render our own QR locally so a join/admin link's secret
+// #fragment never has to leave the browser for some third-party QR service.
+//
+// qrMatrix is pure (no DOM, no globals) so it can be unit-tested in Node; the
+// only browser-only piece is qrCanvas. Tables and the placement/mask algorithm
+// follow ISO/IEC 18004.
+
+const ECL_INDEX = { L: 0, M: 1, Q: 2, H: 3 };
+
+// The 2-bit EC indicator written into the format information is NOT in L/M/Q/H
+// numeric order: L=01, M=00, Q=11, H=10 per the spec.
+const ECL_FORMAT_BITS = { L: 1, M: 0, Q: 3, H: 2 };
+
+// EC codewords per block, indexed [eclIndex][version]. Index 0 of each row is a
+// placeholder so version can index directly. (Nayuki's tables — the part most
+// worth copying verbatim rather than re-deriving.)
+const ECC_CODEWORDS_PER_BLOCK = [
+ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
+ [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+];
+
+// Number of EC blocks, indexed [eclIndex][version].
+const NUM_ERROR_CORRECTION_BLOCKS = [
+ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
+ [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
+ [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
+ [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
+];
+
+const MIN_VERSION = 1;
+const MAX_VERSION = 40;
+const PENALTY_N1 = 3;
+const PENALTY_N2 = 3;
+const PENALTY_N3 = 40;
+const PENALTY_N4 = 10;
+
+// Number of data+EC modules available in a version, before subtracting function
+// patterns' overhead — i.e. the bits the codeword stream is laid into.
+function numRawDataModules(version) {
+ let result = (16 * version + 128) * version + 64;
+ if (version >= 2) {
+ const numAlign = Math.floor(version / 7) + 2;
+ result -= (25 * numAlign - 10) * numAlign - 55;
+ if (version >= 7) result -= 36; // two version-information blocks
+ }
+ return result;
+}
+
+function numDataCodewords(version, eclIndex) {
+ return (
+ Math.floor(numRawDataModules(version) / 8) -
+ ECC_CODEWORDS_PER_BLOCK[eclIndex][version] *
+ NUM_ERROR_CORRECTION_BLOCKS[eclIndex][version]
+ );
+}
+
+// Byte-mode character-count indicator width grows with version.
+function charCountBits(version) {
+ return version <= 9 ? 8 : 16;
+}
+
+// GF(256) multiply with primitive polynomial 0x11D, via Russian-peasant so we
+// avoid log/antilog tables.
+function gfMul(x, y) {
+ let z = 0;
+ for (let i = 7; i >= 0; i--) {
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
+ z ^= ((y >>> i) & 1) * x;
+ }
+ return z & 0xff;
+}
+
+// Generator polynomial coefficients (degree `ecLen`) for Reed–Solomon.
+function rsDivisor(ecLen) {
+ const result = new Uint8Array(ecLen);
+ result[ecLen - 1] = 1; // start with the monomial x^0
+ let root = 1;
+ for (let i = 0; i < ecLen; i++) {
+ // multiply the current product by (x - r^i)
+ for (let j = 0; j < result.length; j++) {
+ result[j] = gfMul(result[j], root);
+ if (j + 1 < result.length) result[j] ^= result[j + 1];
+ }
+ root = gfMul(root, 0x02);
+ }
+ return result;
+}
+
+function rsRemainder(data, divisor) {
+ const result = new Uint8Array(divisor.length);
+ for (const b of data) {
+ const factor = b ^ result[0];
+ result.copyWithin(0, 1);
+ result[result.length - 1] = 0;
+ for (let i = 0; i < result.length; i++) {
+ result[i] ^= gfMul(divisor[i], factor);
+ }
+ }
+ return result;
+}
+
+// Build the bit-padded data codeword stream for the chosen version: mode,
+// count, payload, terminator, byte-align, then the alternating EC/11 pad bytes.
+function dataCodewords(bytes, version, eclIndex) {
+ const capacityBits = numDataCodewords(version, eclIndex) * 8;
+ const bits = [];
+ const push = (value, len) => {
+ for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1);
+ };
+
+ push(0b0100, 4); // byte mode
+ push(bytes.length, charCountBits(version));
+ for (const b of bytes) push(b, 8);
+
+ push(0, Math.min(4, capacityBits - bits.length)); // terminator
+ push(0, (8 - (bits.length % 8)) % 8); // align to byte boundary
+ for (let pad = 0xec; bits.length < capacityBits; pad ^= 0xec ^ 0x11) {
+ push(pad, 8);
+ }
+
+ const out = new Uint8Array(bits.length / 8);
+ for (let i = 0; i < bits.length; i++) {
+ out[i >> 3] |= bits[i] << (7 - (i & 7));
+ }
+ return out;
+}
+
+// Split data into blocks, append RS error correction to each, then interleave
+// data-then-EC across blocks as the spec requires.
+function addEccAndInterleave(data, version, eclIndex) {
+ const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[eclIndex][version];
+ const eccLen = ECC_CODEWORDS_PER_BLOCK[eclIndex][version];
+ const rawCodewords = Math.floor(numRawDataModules(version) / 8);
+ const numShort = numBlocks - (rawCodewords % numBlocks);
+ const shortLen = Math.floor(rawCodewords / numBlocks);
+ const divisor = rsDivisor(eccLen);
+
+ const blocks = [];
+ for (let i = 0, k = 0; i < numBlocks; i++) {
+ const datLen = shortLen - eccLen + (i < numShort ? 0 : 1);
+ const dat = data.slice(k, k + datLen);
+ k += datLen;
+ blocks.push({ dat, ecc: rsRemainder(dat, divisor) });
+ }
+
+ const result = new Uint8Array(rawCodewords);
+ let idx = 0;
+ const maxDatLen = shortLen - eccLen + 1;
+ for (let i = 0; i < maxDatLen; i++) {
+ for (const block of blocks) {
+ // short blocks omit their final data codeword from the last column
+ if (i < block.dat.length) result[idx++] = block.dat[i];
+ }
+ }
+ for (let i = 0; i < eccLen; i++) {
+ for (const block of blocks) result[idx++] = block.ecc[i];
+ }
+ return result;
+}
+
+// Alignment-pattern centre coordinates for a version (empty for version 1).
+function alignmentPositions(version) {
+ if (version === 1) return [];
+ const size = version * 4 + 17;
+ const numAlign = Math.floor(version / 7) + 2;
+ const step =
+ version === 32
+ ? 26
+ : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2;
+ const result = [6];
+ for (let pos = size - 7; result.length < numAlign; pos -= step) {
+ result.splice(1, 0, pos);
+ }
+ return result;
+}
+
+// A QR symbol under construction: holds the module grid plus a parallel mask of
+// which cells are immutable function patterns.
+class QrSymbol {
+ constructor(version, eclIndex) {
+ this.version = version;
+ this.eclIndex = eclIndex;
+ this.ecl = ["L", "M", "Q", "H"][eclIndex];
+ this.size = version * 4 + 17;
+ this.modules = Array.from({ length: this.size }, () =>
+ new Array(this.size).fill(false)
+ );
+ this.isFunction = Array.from({ length: this.size }, () =>
+ new Array(this.size).fill(false)
+ );
+ }
+
+ set(x, y, dark) {
+ this.modules[y][x] = dark;
+ this.isFunction[y][x] = true;
+ }
+
+ drawFinder(cx, cy) {
+ for (let dy = -4; dy <= 4; dy++) {
+ for (let dx = -4; dx <= 4; dx++) {
+ const x = cx + dx;
+ const y = cy + dy;
+ if (x < 0 || x >= this.size || y < 0 || y >= this.size) continue;
+ const dist = Math.max(Math.abs(dx), Math.abs(dy));
+ this.set(x, y, dist !== 2 && dist !== 4); // ring + centre, with separator
+ }
+ }
+ }
+
+ drawAlignment(cx, cy) {
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
+ }
+ }
+ }
+
+ drawFunctionPatterns() {
+ for (let i = 0; i < this.size; i++) {
+ this.set(6, i, i % 2 === 0); // vertical timing
+ this.set(i, 6, i % 2 === 0); // horizontal timing
+ }
+
+ this.drawFinder(3, 3);
+ this.drawFinder(this.size - 4, 3);
+ this.drawFinder(3, this.size - 4);
+
+ const pos = alignmentPositions(this.version);
+ const n = pos.length;
+ for (let i = 0; i < n; i++) {
+ for (let j = 0; j < n; j++) {
+ // skip the three that collide with the finder patterns
+ if (
+ (i === 0 && j === 0) ||
+ (i === 0 && j === n - 1) ||
+ (i === n - 1 && j === 0)
+ ) {
+ continue;
+ }
+ this.drawAlignment(pos[i], pos[j]);
+ }
+ }
+
+ // Reserve format/version areas (real bits drawn later); dummy mask is fine.
+ this.drawFormatBits(0);
+ this.drawVersion();
+ }
+
+ drawFormatBits(mask) {
+ const data = (ECL_FORMAT_BITS[this.ecl] << 3) | mask; // 5 bits
+ let rem = data;
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
+ const bits = ((data << 10) | rem) ^ 0x5412; // 15 bits, masked per spec
+ const bit = (i) => ((bits >> i) & 1) !== 0;
+
+ for (let i = 0; i <= 5; i++) this.set(8, i, bit(i));
+ this.set(8, 7, bit(6));
+ this.set(8, 8, bit(7));
+ this.set(7, 8, bit(8));
+ for (let i = 9; i < 15; i++) this.set(14 - i, 8, bit(i));
+
+ for (let i = 0; i < 8; i++) this.set(this.size - 1 - i, 8, bit(i));
+ for (let i = 8; i < 15; i++) this.set(8, this.size - 15 + i, bit(i));
+ this.set(8, this.size - 8, true); // always-dark module
+ }
+
+ drawVersion() {
+ if (this.version < 7) return;
+ let rem = this.version;
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
+ const bits = (this.version << 12) | rem; // 18 bits
+
+ for (let i = 0; i < 18; i++) {
+ const dark = ((bits >> i) & 1) !== 0;
+ const a = this.size - 11 + (i % 3);
+ const b = Math.floor(i / 3);
+ this.set(a, b, dark);
+ this.set(b, a, dark);
+ }
+ }
+
+ // Lay the interleaved codeword stream into the data region, zig-zagging up and
+ // down pairs of columns from the right, skipping the vertical timing column.
+ drawCodewords(data) {
+ let i = 0;
+ for (let right = this.size - 1; right >= 1; right -= 2) {
+ if (right === 6) right = 5;
+ for (let vert = 0; vert < this.size; vert++) {
+ for (let j = 0; j < 2; j++) {
+ const x = right - j;
+ const upward = ((right + 1) & 2) === 0;
+ const y = upward ? this.size - 1 - vert : vert;
+ if (!this.isFunction[y][x] && i < data.length * 8) {
+ this.modules[y][x] = ((data[i >> 3] >> (7 - (i & 7))) & 1) !== 0;
+ i++;
+ }
+ }
+ }
+ }
+ }
+
+ maskBit(mask, x, y) {
+ switch (mask) {
+ case 0: return (x + y) % 2 === 0;
+ case 1: return y % 2 === 0;
+ case 2: return x % 3 === 0;
+ case 3: return (x + y) % 3 === 0;
+ case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
+ case 5: return ((x * y) % 2) + ((x * y) % 3) === 0;
+ case 6: return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
+ case 7: return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
+ default: throw new Error("bad mask");
+ }
+ }
+
+ applyMask(mask) {
+ for (let y = 0; y < this.size; y++) {
+ for (let x = 0; x < this.size; x++) {
+ if (!this.isFunction[y][x] && this.maskBit(mask, x, y)) {
+ this.modules[y][x] = !this.modules[y][x];
+ }
+ }
+ }
+ }
+
+ penaltyScore() {
+ let result = 0;
+ const size = this.size;
+
+ const addHistory = (runLen, history) => {
+ if (history[0] === 0) runLen += size; // light border before a leading run
+ history.pop();
+ history.unshift(runLen);
+ };
+ // A 1:1:3:1:1 dark/light pattern flanked by 4+ light modules (the finder
+ // look-alike) costs N3 each.
+ const countPatterns = (h) => {
+ const n = h[1];
+ const core = n > 0 && h[2] === n && h[3] === n * 3 && h[4] === n && h[5] === n;
+ return (
+ (core && h[0] >= n * 4 && h[6] >= n ? 1 : 0) +
+ (core && h[6] >= n * 4 && h[0] >= n ? 1 : 0)
+ );
+ };
+ const terminateAndCount = (runColor, runLen, history) => {
+ if (runColor) {
+ addHistory(runLen, history);
+ runLen = 0;
+ }
+ runLen += size; // trailing light border
+ addHistory(runLen, history);
+ return countPatterns(history);
+ };
+
+ // Rule 1 + Rule 3, scanned along rows then columns.
+ for (let y = 0; y < size; y++) {
+ let runColor = false;
+ let runLen = 0;
+ const history = [0, 0, 0, 0, 0, 0, 0];
+ for (let x = 0; x < size; x++) {
+ if (this.modules[y][x] === runColor) {
+ runLen++;
+ if (runLen === 5) result += PENALTY_N1;
+ else if (runLen > 5) result++;
+ } else {
+ addHistory(runLen, history);
+ if (!runColor) result += countPatterns(history) * PENALTY_N3;
+ runColor = this.modules[y][x];
+ runLen = 1;
+ }
+ }
+ result += terminateAndCount(runColor, runLen, history) * PENALTY_N3;
+ }
+ for (let x = 0; x < size; x++) {
+ let runColor = false;
+ let runLen = 0;
+ const history = [0, 0, 0, 0, 0, 0, 0];
+ for (let y = 0; y < size; y++) {
+ if (this.modules[y][x] === runColor) {
+ runLen++;
+ if (runLen === 5) result += PENALTY_N1;
+ else if (runLen > 5) result++;
+ } else {
+ addHistory(runLen, history);
+ if (!runColor) result += countPatterns(history) * PENALTY_N3;
+ runColor = this.modules[y][x];
+ runLen = 1;
+ }
+ }
+ result += terminateAndCount(runColor, runLen, history) * PENALTY_N3;
+ }
+
+ // Rule 2: every 2x2 block of one colour costs N2.
+ for (let y = 0; y < size - 1; y++) {
+ for (let x = 0; x < size - 1; x++) {
+ const c = this.modules[y][x];
+ if (
+ c === this.modules[y][x + 1] &&
+ c === this.modules[y + 1][x] &&
+ c === this.modules[y + 1][x + 1]
+ ) {
+ result += PENALTY_N2;
+ }
+ }
+ }
+
+ // Rule 4: deviation of dark-module proportion from 50%.
+ let dark = 0;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) if (this.modules[y][x]) dark++;
+ }
+ const total = size * size;
+ const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
+ result += k * PENALTY_N4;
+
+ return result;
+ }
+}
+
+// Pick the smallest version whose data capacity fits the byte payload at the
+// requested ECC level.
+function chooseVersion(byteLen, eclIndex) {
+ for (let version = MIN_VERSION; version <= MAX_VERSION; version++) {
+ const capacityBits = numDataCodewords(version, eclIndex) * 8;
+ const neededBits = 4 + charCountBits(version) + 8 * byteLen;
+ if (neededBits <= capacityBits) return version;
+ }
+ return -1;
+}
+
+// Returns { size, modules } where modules[y][x] is true for a dark module.
+// Throws if the text is too long for version 40 at the given ECC level.
+export function qrMatrix(text, ecl = "M") {
+ const eclIndex = ECL_INDEX[ecl];
+ if (eclIndex === undefined) throw new Error(`unknown ECC level: ${ecl}`);
+
+ const bytes = new TextEncoder().encode(text);
+ const version = chooseVersion(bytes.length, eclIndex);
+ if (version < 0) {
+ throw new Error(`data too long: ${bytes.length} bytes exceeds version 40 at level ${ecl}`);
+ }
+
+ const sym = new QrSymbol(version, eclIndex);
+ sym.drawFunctionPatterns();
+ sym.drawCodewords(
+ addEccAndInterleave(dataCodewords(bytes, version, eclIndex), version, eclIndex)
+ );
+
+ // Try every mask, keep the lowest-penalty one. We apply, score, then undo by
+ // re-applying (XOR masks are self-inverse).
+ let bestMask = 0;
+ let bestPenalty = Infinity;
+ for (let mask = 0; mask < 8; mask++) {
+ sym.applyMask(mask);
+ sym.drawFormatBits(mask);
+ const penalty = sym.penaltyScore();
+ if (penalty < bestPenalty) {
+ bestPenalty = penalty;
+ bestMask = mask;
+ }
+ sym.applyMask(mask);
+ }
+ sym.applyMask(bestMask);
+ sym.drawFormatBits(bestMask);
+
+ return { size: sym.size, modules: sym.modules };
+}
+
+// Browser-only: render the matrix to a fresh <canvas> and return it.
+export function qrCanvas(
+ text,
+ { ecl = "M", scale = 6, margin = 4, dark = "#000", light = "#fff" } = {}
+) {
+ const { size, modules } = qrMatrix(text, ecl);
+ const dim = (size + margin * 2) * scale;
+ const canvas = document.createElement("canvas");
+ canvas.width = dim;
+ canvas.height = dim;
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = light;
+ ctx.fillRect(0, 0, dim, dim);
+ ctx.fillStyle = dark;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ if (modules[y][x]) {
+ ctx.fillRect((x + margin) * scale, (y + margin) * scale, scale, scale);
+ }
+ }
+ }
+ return canvas;
+}
diff --git a/web/static/style.css b/web/static/style.css
index dab1eab..c597318 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -583,6 +583,35 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
overflow: auto;
}
+/* invite QR (admin) — full-screen opaque takeover so nothing else is on screen */
+.qr-screen {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background: var(--bg);
+ overflow: auto;
+}
+.qr-modal {
+ margin: 8vh auto;
+ max-width: 360px;
+ text-align: center;
+ border: none;
+ box-shadow: none;
+}
+.qr-modal h2 { margin: 0 0 6px; font-size: 20px; }
+.qr-wrap {
+ display: flex;
+ justify-content: center;
+ margin: 18px 0;
+}
+.qr-wrap canvas {
+ max-width: 100%;
+ height: auto;
+ image-rendering: pixelated; /* keep modules crisp when CSS scales the canvas down */
+ border-radius: 8px;
+}
+.qr-modal .row { justify-content: center; }
+
/* input + button on one row (e.g. Create invite) */
.inline-form {
display: flex;