From f3b2e0d7f3b8f3196169ffe79033c8a229afa9fe Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 23 Sep 2026 17:19:38 -0700 Subject: [PATCH 1/5] feat(site): Print a paper bingo card from the Bingo page People who don't use a smartphone, and partners who hand out cards at events, can now print the same card everyone plays on their phones. The Bingo page has a Print section under "Share your card" with "Include my marks", "Large print (2 pages)" and "Print a card". Both paper layouts are built into the page from the square list, so the printed text always matches the card on screen. The print styles show only the chosen layout: a one-page, black-and-white card on US Letter paper with every square's full text, or a two-page large-print list with rows 3 to 5 on the second page. With "Include my marks" ticked, marked squares print with a check mark and a thick border and the progress line prints under the dates. Without JavaScript, the browser's own Print still prints the blank card. The old /bingo/print page redirects to /bingo. The Bingo page now sends a `bingochange` event with the marks and the progress line after loading and after every change, which is all the Print section reads. The card's QR code opens lvwwd.org/bingo at error correction level M. The build-time QR encoder now splits longer codes into blocks as the standard says and picks the mask with the lowest penalty, so partner links can use level M too. A test decoder written apart from the encoder reads every code back and checks its error correction. Co-Authored-By: Claude Opus 5.5 --- apps/site/public/_redirects | 2 + apps/site/public/scripts/bingo-print.js | 70 +- apps/site/public/scripts/bingo.js | 17 +- .../site/src/components/BingoPrintSheet.astro | 350 ++++++++++ apps/site/src/lib/bingo.ts | 8 +- apps/site/src/lib/qrcode.ts | 649 ++++++++++-------- apps/site/src/pages/bingo.astro | 67 +- apps/site/src/pages/bingo/print.astro | 185 ----- apps/site/tests/e2e/bingo-print.spec.ts | 181 +++++ apps/site/tests/qrcode.test.ts | 252 +++++++ 10 files changed, 1288 insertions(+), 493 deletions(-) create mode 100644 apps/site/src/components/BingoPrintSheet.astro delete mode 100644 apps/site/src/pages/bingo/print.astro create mode 100644 apps/site/tests/e2e/bingo-print.spec.ts create mode 100644 apps/site/tests/qrcode.test.ts diff --git a/apps/site/public/_redirects b/apps/site/public/_redirects index 6ed0a7b..698c3b4 100644 --- a/apps/site/public/_redirects +++ b/apps/site/public/_redirects @@ -1,2 +1,4 @@ # Old participant kit pages, from before sign-up replaced the pledge. /pledged/* /my-week 301 +# The paper bingo card moved onto the Bingo page itself. +/bingo/print /bingo 301 diff --git a/apps/site/public/scripts/bingo-print.js b/apps/site/public/scripts/bingo-print.js index 55a58dd..c095142 100644 --- a/apps/site/public/scripts/bingo-print.js +++ b/apps/site/public/scripts/bingo-print.js @@ -1,9 +1,65 @@ -// Progressive enhancement for the "Print this card" button on -// /bingo/print. Printing already works from the browser's own menu with -// JavaScript off (Ctrl+P / Cmd+P on a computer, Share, then Print on an -// iPhone) — this button is a convenience, not a requirement. +/** + * The Bingo page's Print section: "Include my marks", "Large print + * (2 pages)" and "Print a card". The paper card itself is built into the + * page (src/components/BingoPrintSheet.astro) and printed by the print + * styles, so the browser's own Print menu prints the same card with the + * same choices, and prints the blank card with JavaScript off. + * + * It reads the card only from the `bingochange` event that /scripts/bingo.js + * sends on load and after every change, { marks, progress, locked }, so it + * must load before bingo.js. It makes no request and stores nothing. + */ (() => { - const button = document.querySelector('[data-bingo-print-button]'); - if (!button) return; - button.addEventListener('click', () => window.print()); + const FREE = 12; + const controls = document.querySelector('[data-bingo-print-controls]'); + const sheet = document.querySelector('[data-bingo-print-sheet]'); + if (!controls || !sheet) return; + + const marksField = controls.querySelector('[data-print-marks-field]'); + const marksBox = controls.querySelector('[data-print-marks]'); + const largeBox = controls.querySelector('[data-print-large]'); + const button = controls.querySelector('[data-print-button]'); + const squares = sheet.querySelectorAll('[data-print-square]'); + const progressLines = sheet.querySelectorAll('[data-print-progress]'); + + let latest = { marks: [], progress: '' }; + + function apply() { + const include = Boolean(marksBox?.checked) && !marksField?.hidden; + sheet.toggleAttribute('data-include-marks', include); + squares.forEach((square) => { + const marked = include && Boolean(latest.marks[Number(square.dataset.printSquare)]); + square.toggleAttribute('data-marked', marked); + }); + progressLines.forEach((line) => { + line.textContent = include ? latest.progress : ''; + }); + } + + document.addEventListener('bingochange', (event) => { + const detail = event.detail ?? {}; + latest = { + marks: Array.isArray(detail.marks) ? detail.marks : [], + progress: typeof detail.progress === 'string' ? detail.progress : '', + }; + const anyMarked = latest.marks.some((marked, i) => i !== FREE && marked); + if (marksField && marksBox) { + if (anyMarked && marksField.hidden) marksBox.checked = false; + if (!anyMarked) marksBox.checked = false; + marksField.hidden = !anyMarked; + } + apply(); + }); + + marksBox?.addEventListener('change', apply); + largeBox?.addEventListener('change', () => { + sheet.dataset.layout = largeBox.checked ? 'large' : 'card'; + }); + button?.addEventListener('click', () => window.print()); + + // A page restored from the back button may keep old ticks; start clean. + if (marksBox) marksBox.checked = false; + if (largeBox) largeBox.checked = false; + sheet.dataset.layout = 'card'; + controls.hidden = false; })(); diff --git a/apps/site/public/scripts/bingo.js b/apps/site/public/scripts/bingo.js index f625bb8..58ebf0c 100644 --- a/apps/site/public/scripts/bingo.js +++ b/apps/site/public/scripts/bingo.js @@ -1,8 +1,8 @@ // Digital Transit Bingo's interactivity: marking squares, detecting lines, // switching between Card view and List view, saving progress on the device, -// and the clear/share/print actions. Lives in public/scripts because the -// site's Content Security Policy is `script-src 'self'`, which blocks -// inline scripts — see the comment in bingo.astro. This file is served +// clearing the card, and sharing a link to it. +// Lives in public/scripts because the site's Content Security Policy is +// `script-src 'self'`, which blocks inline scripts. This file is served // as-is (no bundler), so it can't import from src/lib/bingo.ts; the square // layout below (5x5, free square at index 12) must stay in sync with that // file if the card ever changes. @@ -11,6 +11,11 @@ // /scripts/bingo-sync.js also keeps the card on their sign-up, so it // follows them to another phone; it talks to this file through // window.lvwwdBingo and the lvwwd:bingo-saved event. +// +// After loading and after every change, this file sends a `bingochange` +// event on the document with { marks, progress, locked }: the 25 marks, the +// progress line's text, and whether the week is over. The Print section +// (/scripts/bingo-print.js) listens to it, so that script loads first. (() => { const STORAGE_KEY = 'lvwwd_bingo_2026'; const VIEW_KEY = 'lvwwd_bingo_view'; @@ -68,7 +73,6 @@ const dialogShareBtn = document.querySelector('[data-bingo-dialog-share]'); const confettiEl = document.querySelector('[data-bingo-confetti]'); const viewButtons = Array.from(document.querySelectorAll('[data-bingo-view-btn]')); - // --- storage ------------------------------------------------------- function checkStorageAvailable() { try { @@ -212,6 +216,11 @@ function updateProgress(s) { if (progressEl) progressEl.textContent = progressText(s); + document.dispatchEvent( + new CustomEvent('bingochange', { + detail: { marks: s.slice(), progress: progressText(s), locked: isAfterWeek }, + }), + ); } function cellIndex(cell) { diff --git a/apps/site/src/components/BingoPrintSheet.astro b/apps/site/src/components/BingoPrintSheet.astro new file mode 100644 index 0000000..adae3d7 --- /dev/null +++ b/apps/site/src/components/BingoPrintSheet.astro @@ -0,0 +1,350 @@ +--- +import { Icon } from 'astro-icon/components'; +import { BINGO_SQUARES } from '../lib/bingo'; +import { encodeQr, qrPathData, QUIET_ZONE } from '../lib/qrcode'; + +// The paper bingo card. Both layouts are built from the same square list as +// the card on screen, so the printed text always matches it. They are +// hidden on screen and are the only thing on the Bingo page that prints: +// the one-page card on US Letter paper, or the two-page large-print list +// when "Large print (2 pages)" is ticked. /scripts/bingo-print.js switches +// the layout and, when "Include my marks" is ticked, marks the squares. +// Without JavaScript, the browser's own Print prints the blank card. + +const qr = encodeQr('https://lvwwd.org/bingo'); +const qrSide = qr.size + QUIET_ZONE * 2; +const qrPath = qrPathData(qr, QUIET_ZONE); + +const DATES = 'Week Without Driving Las Vegas · October 1 to 8, 2026'; +const HOW_TO_PLAY_CARD = + "Mark a square with a pen when you've done it. Five in a row, down a column, or corner to corner is a bingo. The middle square is free. Every way of getting around counts: by bus, walking or rolling, biking, or getting a ride. If you still have to drive, you haven't failed."; +const HOW_TO_PLAY_LARGE = + "Tick a box with a pen when you've done it. A bingo is all five squares in one row, or the same numbered square in all five rows, or a diagonal: square 1 in row 1, square 2 in row 2, and so on to square 5 in row 5, or square 5 in row 1, square 4 in row 2, and so on to square 1 in row 5. Square 3 in row 3 is free. Every way of getting around counts: by bus, walking or rolling, biking, or getting a ride. If you still have to drive, you haven't failed."; +const QR_CAPTION = 'Scan to play on a phone: lvwwd.org/bingo'; +const FOOTER = [ + 'Share your card, find bus stops near you, and read our guides at lvwwd.org.', + 'Giveaway: sign up free at lvwwd.org/giveaway with an email address or phone number. Share your trip to enter: post a photo of a trip you took without driving and tag @lasvegasfortransit, then send it on My week. Each day you share one is an entry, up to 8. No purchase or post necessary. No social media? You can enter by mail. Official Rules: lvwwd.org/giveaway/rules. Every square counts, prize or not.', + 'Week Without Driving Las Vegas is run by Las Vegans for Better Transit. Questions: wwd@lasvegasfortransit.org', +]; +const rows = [1, 2, 3, 4, 5].map((row) => + BINGO_SQUARES.map((square, index) => ({ ...square, index })).filter((s) => s.row === row), +); +--- + +
+
+
+

Digital Transit Bingo

+

Name:

+
+

{DATES}

+

+

How to play {HOW_TO_PLAY_CARD}

+
+ { + BINGO_SQUARES.map((square, index) => + square.free ? ( +
+ +

WEEK WITHOUT DRIVING

+

Free square

+
+ ) : ( +
+ +

{square.fullText}

+
+ ), + ) + } +
+ +
+ +
+
+

Digital Transit Bingo

+

Name:

+
+

{DATES}

+

+

How to play {HOW_TO_PLAY_LARGE}

+ { + rows.map((squares, r) => ( +
+

Row {r + 1}

+
    + {squares.map((square) => + square.free ? ( +
  • + + + + 3. Week Without Driving (free square) +
  • + ) : ( +
  • + + + + + {square.col}. {square.fullText} + +
  • + ), + )} +
+
+ )) + } + +
+
+ + diff --git a/apps/site/src/lib/bingo.ts b/apps/site/src/lib/bingo.ts index 7a93b41..97543f4 100644 --- a/apps/site/src/lib/bingo.ts +++ b/apps/site/src/lib/bingo.ts @@ -1,8 +1,8 @@ // Digital Transit Bingo's one fixed card. Every visitor plays the same 25 -// squares, so this list is the single source of truth for the on-screen -// card (/bingo) and the printed card (/bingo/print). Copy is exact from -// the Feature "Let participants mark a Digital Transit Bingo card on their -// phone" (bingo/play-bingo.md) — do not paraphrase. +// squares, so this list is the single source of truth for the card on +// screen, the paper card printed from the Bingo page, and the share +// picture's short labels. Keep the wording exactly as it is: players on +// paper and on phones compare cards square by square. // // Squares are listed row-major, row 1 column 1 first, so `BINGO_SQUARES[i]` // sits at row `Math.floor(i / 5) + 1`, column `(i % 5) + 1`. The free diff --git a/apps/site/src/lib/qrcode.ts b/apps/site/src/lib/qrcode.ts index e086f96..0f53f61 100644 --- a/apps/site/src/lib/qrcode.ts +++ b/apps/site/src/lib/qrcode.ts @@ -1,362 +1,449 @@ -// A from-scratch, dependency-free QR Code encoder (ISO/IEC 18004), used only -// at build time (Astro's static render) to produce an inline SVG path — no -// client-side JavaScript, no npm dependency. There is no QR library in the -// pnpm catalog (see pnpm-workspace.yaml), and adding one there would touch a -// file every worktree shares, so this file is the "pre-render the code as an -// SVG at build time" fallback named in the partners build brief. +// A small QR Code encoder (ISO/IEC 18004) used only when the site is built, +// so no QR library ships to phones. It makes the printed bingo card's code +// and every partner's link code as vector paths, and the partner PNG files. // -// Scope: byte mode only, versions 1-5, and only the (version, error -// correction level) combinations that use a single Reed-Solomon block, so no -// codeword interleaving is needed. That covers every string this site draws -// a QR code for: lvwwd.org links, with or without a `?ref=` partner -// slug up to the roster's 40-character limit. A fixed mask pattern (0) is -// used instead of evaluating all eight masks for the lowest penalty score; -// any valid mask produces a fully scannable code, so this trades a little -// theoretical robustness for a much smaller, easier-to-verify implementation. +// Scope: byte mode, versions 1 to 10, any error correction level. The +// caller names the level, and every code the site prints uses level M (it +// still scans with about 15 percent of it damaged or covered), so a longer +// link gets a bigger code rather than a weaker one. Codewords are split into +// blocks and interleaved as the standard says, and the mask with the lowest +// penalty score is chosen, like any other encoder. // -// Verified against the `jsQR` decoder for several lvwwd.org URL lengths -// during development (see the build report for how); re-run that check if -// this file changes. +// tests/qrcode.test.ts reads every code back with its own small decoder and +// checks the error correction, so a change here that breaks a code fails +// the tests. -type EcLevel = 'L' | 'M' | 'Q' | 'H'; +export type EcLevel = 'L' | 'M' | 'Q' | 'H'; -/** Reads `arr[i]`, narrowing away `undefined`. Throws only on a real bug. */ -function idx(arr: readonly T[], i: number): T { - const value = arr[i]; - if (value === undefined) throw new Error(`qrcode: index ${i} out of range`); - return value; +export interface QrCode { + /** Modules along one side, without the quiet zone. */ + size: number; + /** `matrix[row][column]` is true for a dark module. */ + matrix: boolean[][]; + version: number; + level: EcLevel; + mask: number; } -function idx2(arr: readonly (readonly T[])[], row: number, col: number): T { - return idx(idx(arr, row), col); +/** The white border every code needs around it, in modules. */ +export const QUIET_ZONE = 4; + +const MAX_VERSION = 10; + +// Per version (index 1 to 10): error correction codewords in each block, and +// the number of blocks, for each level. From the standard's table 9. +const ECC_PER_BLOCK: Record = { + L: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18], + M: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], + Q: [0, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24], + H: [0, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28], +}; +const BLOCKS: Record = { + L: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4], + M: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5], + Q: [0, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8], + H: [0, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8], +}; +const FORMAT_LEVEL_BITS: Record = { L: 1, M: 0, Q: 3, H: 2 }; + +/** Reads `list[i]`, which the tables above guarantee exists. */ +function at(list: readonly number[], i: number): number { + const value = list[i]; + if (value === undefined) throw new Error(`qrcode: no table entry ${i}`); + return value; } -const GF_EXP: number[] = new Array(256).fill(0); -const GF_LOG: number[] = new Array(256).fill(0); -(() => { - let x = 1; - for (let i = 0; i < 255; i++) { - GF_EXP[i] = x; - GF_LOG[x] = i; - x <<= 1; - if (x & 0x100) x ^= 0x11d; - } - GF_EXP[255] = idx(GF_EXP, 0); -})(); +// ---- Reed-Solomon over GF(256) with the polynomial 0x11d ---------------- -function gfMul(a: number, b: number): number { - if (a === 0 || b === 0) return 0; - return idx(GF_EXP, (idx(GF_LOG, a) + idx(GF_LOG, b)) % 255); +const EXP: number[] = []; +const LOG: number[] = new Array(256).fill(0); +for (let i = 0, x = 1; i < 255; i++) { + EXP.push(x); + LOG[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; } -function multiplyPolys(a: number[], b: number[]): number[] { - const result: number[] = new Array(a.length + b.length - 1).fill(0); - for (let i = 0; i < a.length; i++) { - for (let j = 0; j < b.length; j++) { - result[i + j] = idx(result, i + j) ^ gfMul(idx(a, i), idx(b, j)); - } - } - return result; +function gfMultiply(a: number, b: number): number { + return a === 0 || b === 0 ? 0 : at(EXP, (at(LOG, a) + at(LOG, b)) % 255); } -function generatorPoly(degree: number): number[] { - let g = [1]; +/** The generator polynomial's coefficients, highest power first, without the leading 1. */ +function generator(degree: number): number[] { + let poly = [1]; for (let i = 0; i < degree; i++) { - g = multiplyPolys(g, [1, idx(GF_EXP, i)]); + const next = new Array(poly.length + 1).fill(0); + poly.forEach((c, j) => { + next[j] = at(next, j) ^ c; + next[j + 1] = at(next, j + 1) ^ gfMultiply(c, at(EXP, i)); + }); + poly = next; } - return g; + return poly.slice(1); } -function rsEncode(dataCodewords: number[], eccCount: number): number[] { - const gen = generatorPoly(eccCount); - const remainder: number[] = new Array(eccCount).fill(0); - for (const d of dataCodewords) { - const factor = d ^ idx(remainder, 0); - remainder.shift(); - remainder.push(0); - if (factor !== 0) { - for (let i = 0; i < gen.length - 1; i++) { - remainder[i] = idx(remainder, i) ^ gfMul(idx(gen, i + 1), factor); - } - } +function remainder(data: readonly number[], degree: number): number[] { + const gen = generator(degree); + const result = new Array(degree).fill(0); + for (const byte of data) { + const factor = byte ^ at(result, 0); + result.shift(); + result.push(0); + gen.forEach((c, i) => { + result[i] = at(result, i) ^ gfMultiply(c, factor); + }); } - return remainder; + return result; } -interface VersionInfo { - size: number; - total: number; - align: number | null; +// ---- Sizes ---------------------------------------------------------------- + +function alignmentCenters(version: number): number[] { + if (version === 1) return []; + const count = Math.floor(version / 7) + 2; + const size = version * 4 + 17; + const step = Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2; + const centers = [6]; + for (let pos = size - 7; centers.length < count; pos -= step) centers.splice(1, 0, pos); + return centers; } -// Versions 1-5 only. `align` is the single alignment pattern's center -// (versions 2-6 have exactly one, at (size-7, size-7)); version 1 has none. -const VERSION_INFO: Record = { - 1: { size: 21, total: 26, align: null }, - 2: { size: 25, total: 44, align: 18 }, - 3: { size: 29, total: 70, align: 22 }, - 4: { size: 33, total: 100, align: 26 }, - 5: { size: 37, total: 134, align: 30 }, -}; +/** Modules left for codewords once the patterns are drawn. */ +function rawDataModules(version: number): number { + let modules = (16 * version + 128) * version + 64; + if (version >= 2) { + const count = Math.floor(version / 7) + 2; + modules -= (25 * count - 10) * count - 55; + if (version >= 7) modules -= 36; + } + return modules; +} -// Error-correction codeword counts for the (version, level) pairs that use -// exactly one Reed-Solomon block. Versions/levels that split into multiple -// blocks (e.g. V3-Q, V4-M) are intentionally left out. -const ECC_COUNT: Record>> = { - 1: { L: 7, M: 10, Q: 13, H: 17 }, - 2: { L: 10, M: 16, Q: 22, H: 28 }, - 3: { L: 15, M: 26 }, - 4: { L: 20 }, - 5: { L: 26 }, -}; +function dataCodewords(version: number, level: EcLevel): number { + return ( + Math.floor(rawDataModules(version) / 8) - + at(ECC_PER_BLOCK[level], version) * at(BLOCKS[level], version) + ); +} -const EC_LEVEL_BITS: Record = { L: 0b01, M: 0b00, Q: 0b11, H: 0b10 }; +const countBits = (version: number) => (version <= 9 ? 8 : 16); -function getVersionInfo(version: number): VersionInfo { - const info = VERSION_INFO[version]; - if (!info) throw new Error(`qrcode: unsupported version ${version}`); - return info; +/** The smallest version that holds `bytes` bytes at `level`. */ +function chooseVersion(bytes: number, level: EcLevel): number { + for (let version = 1; version <= MAX_VERSION; version++) { + if (4 + countBits(version) + bytes * 8 <= dataCodewords(version, level) * 8) return version; + } + throw new Error(`qrcode: ${bytes} bytes is too long for a version ${MAX_VERSION} code`); } -// Tried smallest-and-strongest first: for each byte length we want the -// smallest code that still uses a good error correction level. -const CANDIDATES: Array<[number, EcLevel]> = [ - [1, 'M'], - [1, 'L'], - [2, 'M'], - [2, 'L'], - [3, 'M'], - [3, 'L'], - [4, 'L'], - [5, 'L'], -]; +// ---- Codewords ------------------------------------------------------------ -class BitBuffer { - bits: number[] = []; - put(value: number, length: number): void { - for (let i = length - 1; i >= 0; i--) { - this.bits.push((value >>> i) & 1); - } +function encodeData(bytes: readonly number[], version: number, level: EcLevel): number[] { + const bits: number[] = []; + const put = (value: number, length: number) => { + for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1); + }; + put(0b0100, 4); + put(bytes.length, countBits(version)); + bytes.forEach((byte) => put(byte, 8)); + const capacity = dataCodewords(version, level) * 8; + put(0, Math.min(4, capacity - bits.length)); + put(0, (8 - (bits.length % 8)) % 8); + for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8); + + const words: number[] = []; + for (let i = 0; i < bits.length; i += 8) { + words.push(bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0)); } + return words; } -interface VersionChoice { - version: number; - level: EcLevel; - dataCodewords: number; - ecc: number; -} +/** Splits the data into blocks, adds each block's error correction, and interleaves them. */ +function interleave(data: readonly number[], version: number, level: EcLevel): number[] { + const blockCount = at(BLOCKS[level], version); + const eccLength = at(ECC_PER_BLOCK[level], version); + const shortLength = Math.floor(data.length / blockCount); + const longBlocks = data.length % blockCount; + const blocks: number[][] = []; + let offset = 0; + for (let b = 0; b < blockCount; b++) { + const length = shortLength + (b >= blockCount - longBlocks ? 1 : 0); + blocks.push(data.slice(offset, offset + length)); + offset += length; + } + const eccs = blocks.map((block) => remainder(block, eccLength)); -function chooseVersionLevel(byteLength: number): VersionChoice { - for (const [version, level] of CANDIDATES) { - const ecc = ECC_COUNT[version]?.[level]; - if (ecc == null) continue; - const dataCodewords = getVersionInfo(version).total - ecc; - // Byte mode overhead for versions 1-9: 4-bit mode indicator + 8-bit - // character count indicator = 12 bits, i.e. 1.5 bytes. - const capacity = Math.floor((dataCodewords * 8 - 12) / 8); - if (byteLength <= capacity) { - return { version, level, dataCodewords, ecc }; - } + const result: number[] = []; + for (let i = 0; i <= shortLength; i++) { + for (const block of blocks) if (i < block.length) result.push(at(block, i)); + } + for (let i = 0; i < eccLength; i++) { + for (const ecc of eccs) result.push(at(ecc, i)); } - throw new Error(`QR text too long for the supported versions (1-5): ${byteLength} bytes`); + return result; } -function getFormatBits(level: EcLevel, mask: number): number { - const data = (EC_LEVEL_BITS[level] << 3) | mask; // 5 bits - let d = data << 10; - const gen = 0b10100110111; // BCH(15,5) generator, degree 10 - for (let i = 4; i >= 0; i--) { - if ((d >> (10 + i)) & 1) { - d ^= gen << i; - } +// ---- The matrix ----------------------------------------------------------- + +class Grid { + readonly size: number; + readonly modules: boolean[][]; + readonly reserved: boolean[][]; + + constructor(size: number) { + this.size = size; + this.modules = Array.from({ length: size }, () => new Array(size).fill(false)); + this.reserved = Array.from({ length: size }, () => new Array(size).fill(false)); } - const bch = d & 0x3ff; - return ((data << 10) | bch) ^ 0b101010000010010; -} -function buildDataCodewords(text: string, dataCodewords: number, ecc: number): number[] { - const bytes = Array.from(new TextEncoder().encode(text)); - const bb = new BitBuffer(); - bb.put(0b0100, 4); // byte mode indicator - bb.put(bytes.length, 8); // character count indicator (versions 1-9) - for (const byte of bytes) bb.put(byte, 8); - - const totalDataBits = dataCodewords * 8; - const termLen = Math.max(0, Math.min(4, totalDataBits - bb.bits.length)); - bb.put(0, termLen); - while (bb.bits.length % 8 !== 0) bb.bits.push(0); - - const padBytes = [0xec, 0x11]; - let padIndex = 0; - while (bb.bits.length < totalDataBits) { - bb.put(idx(padBytes, padIndex % 2), 8); - padIndex++; + dark(row: number, col: number): boolean { + return this.modules[row]?.[col] ?? false; } - const dataCw: number[] = []; - for (let i = 0; i < bb.bits.length; i += 8) { - let byte = 0; - for (let j = 0; j < 8; j++) byte = (byte << 1) | idx(bb.bits, i + j); - dataCw.push(byte); + isReserved(row: number, col: number): boolean { + return this.reserved[row]?.[col] ?? false; } - return dataCw.concat(rsEncode(dataCw, ecc)); -} + set(row: number, col: number, dark: boolean): void { + const line = this.modules[row]; + if (line && col >= 0 && col < this.size) line[col] = dark; + } -function buildFinalBits(allCodewords: number[], version: number): BitBuffer { - // Remainder bits after the codewords: 0 for version 1, 7 for versions 2-6. - const remainderBits = version === 1 ? 0 : 7; - const finalBits = new BitBuffer(); - for (const cw of allCodewords) finalBits.put(cw, 8); - finalBits.put(0, remainderBits); - return finalBits; + /** Sets a pattern module, which the data and the mask never touch. */ + fix(row: number, col: number, dark: boolean): void { + if (row < 0 || row >= this.size || col < 0 || col >= this.size) return; + this.set(row, col, dark); + const line = this.reserved[row]; + if (line) line[col] = true; + } } -type SetModule = (row: number, col: number, dark: boolean) => void; +function drawPatterns(grid: Grid, version: number): void { + const { size } = grid; + for (let i = 0; i < size; i++) { + grid.fix(6, i, i % 2 === 0); + grid.fix(i, 6, i % 2 === 0); + } + for (const [top, left] of [ + [0, 0], + [0, size - 7], + [size - 7, 0], + ] as const) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const ring = Math.max(Math.abs(r - 3), Math.abs(c - 3)); + grid.fix(top + r, left + c, ring !== 2 && ring !== 4); + } + } + } + const centers = alignmentCenters(version); + const last = centers.length - 1; + centers.forEach((row, i) => { + centers.forEach((col, j) => { + const nearFinder = (i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0); + if (nearFinder) return; + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) + grid.fix(row + r, col + c, Math.max(Math.abs(r), Math.abs(c)) !== 1); + } + }); + }); + drawFormat(grid, 'M', 0); // reserves the format areas; redrawn with the real values later + if (version >= 7) { + let rem = version; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits = (version << 12) | rem; + for (let i = 0; i < 18; i++) { + const dark = ((bits >>> i) & 1) === 1; + const a = size - 11 + (i % 3); + const b = Math.floor(i / 3); + grid.fix(b, a, dark); + grid.fix(a, b, dark); + } + } +} -function createGrids(size: number): { matrix: boolean[][]; isFn: boolean[][] } { - const matrix: boolean[][] = Array.from({ length: size }, () => - new Array(size).fill(false), - ); - const isFn: boolean[][] = Array.from({ length: size }, () => - new Array(size).fill(false), - ); - return { matrix, isFn }; +function formatBits(level: EcLevel, mask: number): number { + const data = (FORMAT_LEVEL_BITS[level] << 3) | mask; + let rem = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + return ((data << 10) | rem) ^ 0x5412; } -function isFinderDark(r: number, c: number): boolean { - return ( - (r >= 0 && r <= 6 && (c === 0 || c === 6)) || - (c >= 0 && c <= 6 && (r === 0 || r === 6)) || - (r >= 2 && r <= 4 && c >= 2 && c <= 4) - ); +function drawFormat(grid: Grid, level: EcLevel, mask: number): void { + const { size } = grid; + const bits = formatBits(level, mask); + const bit = (i: number) => ((bits >>> i) & 1) === 1; + for (let i = 0; i <= 5; i++) grid.fix(i, 8, bit(i)); + grid.fix(7, 8, bit(6)); + grid.fix(8, 8, bit(7)); + grid.fix(8, 7, bit(8)); + for (let i = 9; i < 15; i++) grid.fix(8, 14 - i, bit(i)); + for (let i = 0; i < 8; i++) grid.fix(8, size - 1 - i, bit(i)); + for (let i = 8; i < 15; i++) grid.fix(size - 15 + i, 8, bit(i)); + grid.fix(size - 8, 8, true); } -function drawFinder(size: number, setFn: SetModule, row: number, col: number): void { - for (let r = -1; r <= 7; r++) { - for (let c = -1; c <= 7; c++) { - const rr = row + r; - const cc = col + c; - if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; - setFn(rr, cc, isFinderDark(r, c)); +/** Every module the codewords fill, in the standard's two-column zigzag order. */ +function dataModuleOrder(size: number, reserved: (row: number, col: number) => boolean) { + const order: Array<[number, number]> = []; + for (let right = size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + const upward = ((right + 1) & 2) === 0; + for (let step = 0; step < size; step++) { + const row = upward ? size - 1 - step : step; + for (const col of [right, right - 1]) if (!reserved(row, col)) order.push([row, col]); } } + return order; } -function drawTimingPatterns(size: number, setFn: SetModule): void { - for (let i = 8; i <= size - 9; i++) { - const dark = i % 2 === 0; - setFn(6, i, dark); - setFn(i, 6, dark); - } -} +const MASKS: ReadonlyArray<(row: number, col: number) => boolean> = [ + (r, c) => (r + c) % 2 === 0, + (r) => r % 2 === 0, + (_r, c) => c % 3 === 0, + (r, c) => (r + c) % 3 === 0, + (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, + (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0, + (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, + (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0, +]; -function drawAlignmentPattern(align: number | null, setFn: SetModule): void { - if (align == null) return; - for (let r = -2; r <= 2; r++) { - for (let c = -2; c <= 2; c++) { - setFn(align + r, align + c, Math.max(Math.abs(r), Math.abs(c)) !== 1); +function applyMask(grid: Grid, mask: number): void { + const flip = MASKS[mask]; + if (!flip) return; + for (let r = 0; r < grid.size; r++) { + for (let c = 0; c < grid.size; c++) { + if (!grid.isReserved(r, c) && flip(r, c)) grid.set(r, c, !grid.dark(r, c)); } } } -function drawFunctionPatterns(size: number, align: number | null, setFn: SetModule): void { - drawFinder(size, setFn, 0, 0); - drawFinder(size, setFn, size - 7, 0); - drawFinder(size, setFn, 0, size - 7); - drawTimingPatterns(size, setFn); - drawAlignmentPattern(align, setFn); - setFn(size - 8, 8, true); // dark module, fixed position -} +const FINDER_LIKE = [true, false, true, true, true, false, true]; -function drawFormatInfo(size: number, level: EcLevel, mask: number, setFn: SetModule): void { - const fmt = getFormatBits(level, mask); - const bit = (i: number): boolean => ((fmt >> i) & 1) === 1; - for (let i = 0; i <= 5; i++) setFn(i, 8, bit(i)); - setFn(7, 8, bit(6)); - setFn(8, 8, bit(7)); - setFn(8, 7, bit(8)); - for (let i = 9; i <= 14; i++) setFn(8, 14 - i, bit(i)); - for (let i = 0; i <= 7; i++) setFn(8, size - 1 - i, bit(i)); - for (let i = 8; i <= 14; i++) setFn(size - 15 + i, 8, bit(i)); +/** Rows and columns of the grid, each as a list of dark or light modules. */ +function linesOf(grid: Grid): boolean[][] { + const lines: boolean[][] = []; + for (let i = 0; i < grid.size; i++) { + lines.push(Array.from({ length: grid.size }, (_, j) => grid.dark(i, j))); + lines.push(Array.from({ length: grid.size }, (_, j) => grid.dark(j, i))); + } + return lines; } -interface Grid { - matrix: boolean[][]; - isFn: boolean[][]; - size: number; +/** Runs of five or more modules of one color in a line. */ +function runPenalty(line: readonly boolean[]): number { + let score = 0; + let run = 1; + for (let i = 1; i <= line.length; i++) { + if (i < line.length && line[i] === line[i - 1]) { + run++; + } else { + if (run >= 5) score += 3 + (run - 5); + run = 1; + } + } + return score; } -interface PlacementCursor { - bitIndex: number; - col: number; - upward: boolean; +/** Patterns in a line that look like a finder, with light space on one side. */ +function finderPenalty(line: readonly boolean[]): number { + const light = new Array(4).fill(false); + const padded = [...light, ...line, ...light]; + let score = 0; + for (let i = 4; i + 11 <= padded.length; i++) { + if (!FINDER_LIKE.every((dark, k) => padded[i + k] === dark)) continue; + const clearBefore = padded.slice(i - 4, i).every((dark) => !dark); + const clearAfter = padded.slice(i + 7, i + 11).every((dark) => !dark); + if (clearBefore || clearAfter) score += 40; + } + return score; } -function placeDataColumn(grid: Grid, bits: number[], cursor: PlacementCursor): void { - const { matrix, isFn, size } = grid; - const { col, upward } = cursor; - for (let i = 0; i < size; i++) { - const row = upward ? size - 1 - i : i; - for (const c of [col, col - 1]) { - if (idx2(isFn, row, c)) continue; - const b = cursor.bitIndex < bits.length ? idx(bits, cursor.bitIndex) : 0; - cursor.bitIndex++; - const invert = (row + c) % 2 === 0; // mask 0 - idx(matrix, row)[c] = invert ? b === 0 : b === 1; +/** Two-by-two blocks of one color. */ +function blockPenalty(grid: Grid): number { + let score = 0; + for (let r = 0; r + 1 < grid.size; r++) { + for (let c = 0; c + 1 < grid.size; c++) { + const color = grid.dark(r, c); + const same = + grid.dark(r, c + 1) === color && + grid.dark(r + 1, c) === color && + grid.dark(r + 1, c + 1) === color; + if (same) score += 3; } } + return score; } -function placeDataBits(grid: Grid, bits: number[]): void { - const cursor: PlacementCursor = { bitIndex: 0, col: grid.size - 1, upward: true }; - while (cursor.col > 0) { - if (cursor.col === 6) cursor.col--; // skip the vertical timing column - placeDataColumn(grid, bits, cursor); - cursor.upward = !cursor.upward; - cursor.col -= 2; - } +/** How far the share of dark modules is from half. */ +function balancePenalty(grid: Grid): number { + const total = grid.size * grid.size; + const dark = grid.modules.flat().filter(Boolean).length; + return (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10; } -export interface QrCode { - size: number; - matrix: boolean[][]; - version: number; - level: EcLevel; +/** The standard's penalty score; the mask with the lowest one is used. */ +function penalty(grid: Grid): number { + const lines = linesOf(grid); + const perLine = lines.reduce((sum, line) => sum + runPenalty(line) + finderPenalty(line), 0); + return perLine + blockPenalty(grid) + balancePenalty(grid); } -/** Encodes `text` (byte mode) into a QR code matrix, ready to render as SVG. */ -export function encodeQr(text: string): QrCode { +/** Encodes `text` as a QR code at the given error correction level (M unless told otherwise). */ +export function encodeQr(text: string, level: EcLevel = 'M'): QrCode { const bytes = Array.from(new TextEncoder().encode(text)); - const { version, level, dataCodewords, ecc } = chooseVersionLevel(bytes.length); - const { size, align } = getVersionInfo(version); - - const allCw = buildDataCodewords(text, dataCodewords, ecc); - const finalBits = buildFinalBits(allCw, version); - - const { matrix, isFn } = createGrids(size); - const setFn: SetModule = (row, col, dark) => { - if (row < 0 || row >= size || col < 0 || col >= size) return; - idx(matrix, row)[col] = dark; - idx(isFn, row)[col] = true; + const version = chooseVersion(bytes.length, level); + const codewords = interleave(encodeData(bytes, version, level), version, level); + const size = version * 4 + 17; + + const build = (mask: number) => { + const grid = new Grid(size); + drawPatterns(grid, version); + const order = dataModuleOrder(size, (r, c) => grid.isReserved(r, c)); + order.forEach(([row, col], i) => { + const byte = codewords[i >>> 3]; + if (byte !== undefined) grid.set(row, col, ((byte >>> (7 - (i & 7))) & 1) === 1); + }); + applyMask(grid, mask); + drawFormat(grid, level, mask); + return grid; }; - drawFunctionPatterns(size, align, setFn); - drawFormatInfo(size, level, 0, setFn); - placeDataBits({ matrix, isFn, size }, finalBits.bits); - - return { size, matrix, version, level }; + let best = { grid: build(0), mask: 0 }; + let bestScore = penalty(best.grid); + for (let mask = 1; mask < 8; mask++) { + const grid = build(mask); + const score = penalty(grid); + if (score < bestScore) { + best = { grid, mask }; + bestScore = score; + } + } + return { size, matrix: best.grid.modules, version, level, mask: best.mask }; } -/** Builds a single SVG `` `d` attribute for every dark module, one unit per module. */ -export function qrPathData(qr: QrCode): string { +/** One SVG path `d` for every dark module, one unit per module, offset by the quiet zone. */ +export function qrPathData(qr: QrCode, offset = 0): string { let d = ''; - for (let r = 0; r < qr.size; r++) { - const row = idx(qr.matrix, r); - for (let c = 0; c < qr.size; c++) { - if (idx(row, c)) d += `M${c} ${r}h1v1h-1z`; - } - } + qr.matrix.forEach((row, r) => { + row.forEach((dark, c) => { + if (dark) d += `M${c + offset} ${r + offset}h1v1h-1z`; + }); + }); return d; } + +/** A standalone SVG file of the code: black on white, with its quiet zone. */ +export function qrSvg(qr: QrCode, title: string): string { + const side = qr.size + QUIET_ZONE * 2; + const escaped = title.replace(/&/g, '&').replace(/`, + `${escaped}`, + ``, + ``, + '', + '', + ].join('\n'); +} diff --git a/apps/site/src/pages/bingo.astro b/apps/site/src/pages/bingo.astro index 2e5aebf..0101539 100644 --- a/apps/site/src/pages/bingo.astro +++ b/apps/site/src/pages/bingo.astro @@ -1,21 +1,19 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import WinBand from '../components/WinBand.astro'; +import BingoPrintSheet from '../components/BingoPrintSheet.astro'; import { Icon } from 'astro-icon/components'; import { BINGO_SQUARES } from '../lib/bingo'; import { wwd, WWD_DATE_RANGE } from '../lib/wwd'; -// Digital Transit Bingo, /bingo. Copy and behavior are from the Feature -// "Let participants mark a Digital Transit Bingo card on their phone" -// (features/bingo/play-bingo.md) and "Let anyone print a paper bingo card" -// (features/bingo/print-bingo-card.md). The board's interactivity — marking, -// lines, List view, saving, printing — lives in /scripts/bingo.js because -// the site's Content Security Policy blocks inline scripts; this page must +// Digital Transit Bingo, /bingo. The board's interactivity (marking, +// lines, List view, saving, sharing and printing) lives in /scripts because +// the site's Content Security Policy blocks inline scripts; the page must // still be readable with JavaScript off, handled by the + + + + diff --git a/apps/site/tests/bingo-picture.test.ts b/apps/site/tests/bingo-picture.test.ts new file mode 100644 index 0000000..8ba2af8 --- /dev/null +++ b/apps/site/tests/bingo-picture.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +import { describe, expect, it } from 'vitest'; + +import { BINGO_SQUARES } from '../src/lib/bingo'; + +/** + * Runs the real public/scripts/bingo-picture.js in a sandbox and checks the + * picture definition, the count line, the image description, how labels + * fit their tiles, and the contrast of every text color. The drawing itself + * is checked in a real browser by tests/e2e/bingo-share.spec.ts. + */ + +interface Fit { + size: number; + lines: string[]; + lineHeight: number; +} +interface Picture { + make( + marks: boolean[], + squares: Array<{ label: string }>, + ): { + heading: string; + fileName: string; + width: number; + height: number; + choices: unknown; + note: string; + describe(): string; + }; + countLine(marks: boolean[]): string; + fitLabel(measure: (text: string, size: number) => number, text: string, top?: number): Fit; + tileBox(i: number): { x: number; y: number; size: number }; + TEXT_PAIRS: Array<[string, string, string]>; + FREE_LABEL_TOP: number; +} + +function load(): Picture { + const source = readFileSync( + new URL('../public/scripts/bingo-picture.js', import.meta.url), + 'utf8', + ); + const window: { lvwwdBingoPicture?: Picture } = {}; + vm.runInNewContext(source, { window }); + if (!window.lvwwdBingoPicture) throw new Error('The script did not set window.lvwwdBingoPicture'); + return window.lvwwdBingoPicture; +} + +const picture = load(); +// The test's own copy of a square list in the documented shape: the real +// labels, so the example description reads exactly as written. +const squares = BINGO_SQUARES.map((square) => ({ + label: square.label, + free: Boolean(square.free), +})); + +function marks(indexes: number[]): boolean[] { + return Array.from({ length: 25 }, (_, i) => i === 12 || indexes.includes(i)); +} + +const ROW_THREE = [10, 11, 13, 14]; +const ALL = Array.from({ length: 25 }, (_, i) => i); + +describe('the bingo card picture', () => { + it('is a 1080 by 1920 picture with the share screen texts and no headline choices', () => { + const definition = picture.make(marks(ROW_THREE), squares); + expect(definition).toMatchObject({ + heading: 'Share your card', + fileName: 'lvwwd-bingo.png', + width: 1080, + height: 1920, + note: "A bingo card doesn't count as a trip. To enter, share a trip on My week.", + }); + expect(definition.choices).toBeFalsy(); + }); + + it('describes the four row-3 squares exactly as written', () => { + expect(picture.make(marks(ROW_THREE), squares).describe()).toBe( + "Graphic: my Digital Transit Bingo card for Week Without Driving Las Vegas, October 1 to 8, 2026. 4 squares marked and 1 bingo. Marked: Worked from home, Found my nearest stop, Asked a nondriver, One fix I'd make. Bingo lines: row 3. lvwwd.org. #WeekWithoutDriving", + ); + expect(picture.countLine(marks(ROW_THREE))).toBe('4 squares · 1 bingo'); + }); + + it('says "Ready to play" with only the free square', () => { + expect(picture.countLine(marks([]))).toBe('Ready to play'); + expect(picture.make(marks([]), squares).describe()).toBe( + 'Graphic: my Digital Transit Bingo card for Week Without Driving Las Vegas, October 1 to 8, 2026. No squares marked yet; the middle square is free. lvwwd.org. #WeekWithoutDriving', + ); + }); + + it('counts one square in the singular', () => { + expect(picture.countLine(marks([0]))).toBe('1 square'); + expect(picture.make(marks([0]), squares).describe()).toContain( + '1 square marked, no bingo yet.', + ); + }); + + it('counts several squares with no bingo', () => { + expect(picture.countLine(marks([0, 1, 2]))).toBe('3 squares'); + expect(picture.make(marks([0, 1, 2]), squares).describe()).toContain( + '3 squares marked, no bingo yet.', + ); + }); + + it('never counts the free square, even when it is passed in as unmarked', () => { + const withoutFree = marks([0]); + withoutFree[12] = false; + expect(picture.countLine(withoutFree)).toBe('1 square'); + }); + + it('describes a full card with no marked or bingo sentence', () => { + const full = marks(ALL); + expect(picture.countLine(full)).toBe('All 24 squares · 12 bingos'); + const text = picture.make(full, squares).describe(); + expect(text).toContain('All 24 squares marked and 12 bingos.'); + expect(text).not.toContain('Marked:'); + expect(text).not.toContain('Bingo lines:'); + }); + + it('names finished lines rows first, then columns, then diagonals, with a final "and"', () => { + const rowOneColumnOneDiagonal = marks([0, 1, 2, 3, 4, 5, 10, 15, 20, 6, 18, 24]); + const text = picture.make(rowOneColumnOneDiagonal, squares).describe(); + expect(text).toContain('Bingo lines: row 1, column 1 and the diagonal from top left.'); + expect(picture.countLine(rowOneColumnOneDiagonal)).toBe('12 squares · 3 bingos'); + }); + + it('lists marked squares in card order, whatever order they were marked in', () => { + const text = picture.make(marks([24, 0, 7]), squares).describe(); + expect(text).toContain('Marked: Bus instead of driving, New bus route, Helped plan a trip.'); + }); + + it('lays out 25 tiles of 188 pixels with 5-pixel gaps, from x 60 to 1020 and y 480 to 1440', () => { + const first = picture.tileBox(0); + const last = picture.tileBox(24); + expect(first).toEqual({ x: 60, y: 480, size: 188 }); + expect(last.x + last.size).toBe(1020); + expect(last.y + last.size).toBe(1440); + expect(picture.tileBox(1).x - (first.x + first.size)).toBe(5); + }); + + it('fits every label inside its tile, shrinking long ones but never below 20 pixels', () => { + // A generous stand-in for a real font: every character as wide as 0.6 of the size. + const measure = (text: string, size: number) => text.length * size * 0.6; + for (const square of squares) { + const top = square.free ? picture.FREE_LABEL_TOP : 56; + const fit = picture.fitLabel(measure, square.label, top); + expect(fit.size, square.label).toBeGreaterThanOrEqual(20); + expect(fit.size, square.label).toBeLessThanOrEqual(28); + expect(fit.lines.length, square.label).toBeLessThanOrEqual(4); + expect(fit.lines.join(' '), square.label).toBe(square.label); + for (const line of fit.lines) expect(measure(line, fit.size), line).toBeLessThanOrEqual(164); + expect(top + fit.lines.length * fit.lineHeight, square.label).toBeLessThanOrEqual(188); + } + }); + + it('keeps every text color at 4.5 to 1 or more against its background', () => { + const channel = (value: number) => { + const c = value / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luminance = (hex: string) => { + const [r, g, b] = [1, 3, 5].map((i) => channel(parseInt(hex.slice(i, i + 2), 16))); + return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0); + }; + expect(picture.TEXT_PAIRS.length).toBeGreaterThanOrEqual(5); + for (const [name, text, background] of picture.TEXT_PAIRS) { + const [light, dark] = [luminance(text), luminance(background)].sort((a, b) => b - a); + const ratio = ((light ?? 0) + 0.05) / ((dark ?? 0) + 0.05); + expect(ratio, name).toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/apps/site/tests/e2e/bingo-share.spec.ts b/apps/site/tests/e2e/bingo-share.spec.ts new file mode 100644 index 0000000..d3bb5ae --- /dev/null +++ b/apps/site/tests/e2e/bingo-share.spec.ts @@ -0,0 +1,306 @@ +import { expect, test, type Page } from '@playwright/test'; + +// The bingo card picture as the browser draws it, and the share screen that +// shows it. Pixel checks read the drawn canvas; they look for colors in +// places the layout fixes, not for an exact image. + +const ROW_THREE = [ + 'Worked from home', + 'Found my nearest stop', + 'Asked a nondriver', + "One fix I'd make", +]; + +async function mark(page: Page, labels: string[]) { + for (const label of labels) { + await page.getByRole('checkbox', { name: new RegExp(`^${label}`) }).click(); + } +} + +interface DrawnText { + text: string; + x: number; + y: number; + font: string; + width: number; +} +interface Box { + x: number; + y: number; + size: number; +} +interface PictureApi { + make: ( + marks: boolean[], + squares: unknown[], + ) => { width: number; height: number; fonts: string[]; draw: (c: HTMLCanvasElement) => void }; + tileBox: (i: number) => Box; +} +interface DrawnWindow { + lvwwdBingoPicture: PictureApi; + drawn?: { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D }; +} + +function squaresOnPage(): unknown[] { + return JSON.parse(document.getElementById('bingo-squares')?.textContent ?? '[]') as unknown[]; +} + +/** + * Draws the picture for the given marks on a fresh canvas, keeps the canvas + * on the page for the checks below, and returns every text it drew. + */ +async function draw(page: Page, marked: number[]) { + await page.addScriptTag({ content: `window.squaresOnPage = ${squaresOnPage.toString()};` }); + return page.evaluate(async (indexes) => { + const w = window as unknown as DrawnWindow & { squaresOnPage: () => unknown[] }; + const marks = Array.from({ length: 25 }, (_, i) => i === 12 || indexes.includes(i)); + const definition = w.lvwwdBingoPicture.make(marks, w.squaresOnPage()); + await Promise.all(definition.fonts.map((font) => document.fonts.load(font))); + const canvas = document.createElement('canvas'); + canvas.width = definition.width; + canvas.height = definition.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('No canvas'); + const texts: DrawnText[] = []; + const fillText = context.fillText.bind(context); + context.fillText = (text: string, x: number, y: number) => { + texts.push({ text, x, y, font: context.font, width: context.measureText(text).width }); + fillText(text, x, y); + }; + Object.defineProperty(canvas, 'getContext', { value: () => context }); + definition.draw(canvas); + w.drawn = { canvas, context }; + return { width: canvas.width, height: canvas.height, texts }; + }, marked); +} + +/** The drawn canvas's colors at the given points, as #RRGGBB. */ +async function colorsAt(page: Page, points: Array<[number, number]>): Promise { + return page.evaluate((list) => { + const { context } = (window as unknown as DrawnWindow).drawn ?? {}; + if (!context) throw new Error('Nothing drawn'); + return list.map(([x, y]) => { + const [r = 0, g = 0, b = 0] = context.getImageData(x, y, 1, 1).data; + return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`.toUpperCase(); + }); + }, points); +} + +/** How many pixels of `color` are in the rectangle, sampling every other pixel. */ +async function countColor(page: Page, rect: [number, number, number, number], color: string) { + return page.evaluate( + ([[x, y, width, height], hex]) => { + const { context } = (window as unknown as DrawnWindow).drawn ?? {}; + if (!context) throw new Error('Nothing drawn'); + const data = context.getImageData(x, y, width, height).data; + const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + let count = 0; + for (let i = 0; i < data.length; i += 8) { + if (data[i] === want[0] && data[i + 1] === want[1] && data[i + 2] === want[2]) count++; + } + return count; + }, + [rect, color] as const, + ); +} + +/** Pixels that are not the background above y 250 or below y 1670. */ +async function strayPixels(page: Page): Promise { + const [top, bottom] = await Promise.all([ + countColor(page, [0, 0, 1080, 250], '#0F1115'), + countColor(page, [0, 1671, 1080, 249], '#0F1115'), + ]); + return (1080 * 250) / 2 - top + (1080 * 249) / 2 - bottom; +} + +async function tileBoxes(page: Page): Promise { + return page.evaluate(() => + Array.from({ length: 25 }, (_, i) => + (window as unknown as DrawnWindow).lvwwdBingoPicture.tileBox(i), + ), + ); +} + +/** For each tile: its fill low on the left, its left edge, and white pixels where a check goes. */ +async function readTiles(page: Page) { + const boxes = await tileBoxes(page); + const fills = await colorsAt( + page, + boxes.map((box) => [box.x + 20, box.y + box.size - 16]), + ); + const edges = await colorsAt( + page, + boxes.map((box) => [box.x + 4, box.y + box.size / 2]), + ); + const checks = await Promise.all( + boxes.map((box) => countColor(page, [box.x + box.size - 60, box.y + 12, 48, 48], '#FFFFFF')), + ); + return boxes.map((box, i) => ({ box, fill: fills[i], edge: edges[i], check: checks[i] ?? 0 })); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/bingo'); + await page.evaluate(() => window.localStorage.clear()); + await page.reload(); +}); + +test('the picture for the row-3 squares shows their checks, the star and the finished line', async ({ + page, +}) => { + const requests: string[] = []; + page.on('request', (request) => requests.push(request.url())); + const drawn = await draw(page, [10, 11, 13, 14]); + + expect([drawn.width, drawn.height]).toEqual([1080, 1920]); + expect(drawn.texts.find((t) => t.y === 390)?.text).toBe('4 squares · 1 bingo'); + const tiles = await readTiles(page); + tiles.forEach((tile, i) => { + if (i === 12) return; + // A check is white on the marked tile's red; an unmarked tile is white + // all over, with no red and so no check. + const checked = [10, 11, 13, 14].includes(i); + expect(tile.fill, `fill of tile ${i}`).toBe(checked ? '#B8360F' : '#FFFFFF'); + if (checked) expect(tile.check, `check on tile ${i}`).toBeGreaterThan(20); + expect(tile.edge === '#FF8A5C', `outline on tile ${i}`).toBe(i >= 10 && i <= 14); + }); + const free = tiles[12]?.box; + if (!free) throw new Error('No free tile'); + expect(await colorsAt(page, [[free.x + free.size / 2, free.y + 40]])).toEqual(['#0F1115']); + expect(requests).toEqual([]); +}); + +test('every square shows its short label, inside its tile', async ({ page }) => { + const drawn = await draw(page, []); + const squares = (await page.evaluate(squaresOnPage)) as Array<{ label: string }>; + const boxes = await tileBoxes(page); + squares.forEach((square, i) => { + const box = boxes[i]; + if (!box) throw new Error(`No tile ${i}`); + const lines = drawn.texts.filter( + (t) => t.y >= box.y && t.y < box.y + box.size && Math.abs(t.x - (box.x + box.size / 2)) < 1, + ); + expect(lines.map((t) => t.text).join(' '), `tile ${i}`).toBe(square.label); + for (const line of lines) { + const size = Number(/(\d+)px/.exec(line.font)?.[1]); + expect(size, line.text).toBeGreaterThanOrEqual(20); + expect(line.width, line.text).toBeLessThanOrEqual(box.size - 16); + expect(line.y + size * 1.15, line.text).toBeLessThanOrEqual(box.y + box.size); + } + }); +}); + +test('nothing is drawn above y 250 or below y 1670, for an empty and a full card', async ({ + page, +}) => { + await draw(page, []); + expect(await strayPixels(page)).toBe(0); + const full = await draw( + page, + Array.from({ length: 25 }, (_, i) => i), + ); + expect(await strayPixels(page)).toBe(0); + expect(full.texts.find((t) => t.y === 390)?.text).toBe('All 24 squares · 12 bingos'); + const tiles = await readTiles(page); + tiles.forEach((tile, i) => { + if (i === 12) return; + expect(tile.fill, `fill of tile ${i}`).toBe('#B8360F'); + expect(tile.check, `check on tile ${i}`).toBeGreaterThan(20); + }); +}); + +test('the share screen shows the picture, its description and the two lines', async ({ page }) => { + await mark(page, ROW_THREE); + await page.getByRole('button', { name: 'Keep playing' }).click(); + const share = page.getByRole('button', { name: 'Share your card' }); + await share.click(); + + const screen = page.getByRole('dialog', { name: 'Share your card' }); + await expect(screen).toBeVisible(); + await expect(screen.getByRole('heading', { name: 'Share your card' })).toBeFocused(); + const description = + "Graphic: my Digital Transit Bingo card for Week Without Driving Las Vegas, October 1 to 8, 2026. 4 squares marked and 1 bingo. Marked: Worked from home, Found my nearest stop, Asked a nondriver, One fix I'd make. Bingo lines: row 3. lvwwd.org. #WeekWithoutDriving"; + await expect(screen.getByRole('img', { name: description })).toBeVisible(); + await expect(screen.getByText(description, { exact: true })).toBeVisible(); + await expect(screen.getByText('Image description', { exact: true })).toBeVisible(); + await expect( + screen.getByText('The image is made on your phone. Nothing is uploaded.'), + ).toBeVisible(); + await expect( + screen.getByText("A bingo card doesn't count as a trip. To enter, share a trip on My week."), + ).toBeVisible(); + const size = await screen + .getByRole('img', { name: description }) + .evaluate((img: HTMLImageElement) => [img.naturalWidth, img.naturalHeight]); + expect(size).toEqual([1080, 1920]); +}); + +test('Tab moves through Close, the share button and the copy button; Escape returns focus', async ({ + page, +}) => { + const share = page.getByRole('button', { name: 'Share your card' }); + await share.focus(); + await page.keyboard.press('Enter'); + const screen = page.getByRole('dialog', { name: 'Share your card' }); + await expect(screen.getByRole('img')).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(screen.getByRole('button', { name: 'Close' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(screen.getByRole('button', { name: /^(Share|Save) image$/ })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(screen.getByRole('button', { name: 'Copy image description' })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(screen).toBeHidden(); + await expect(share).toBeFocused(); + expect(new URL(page.url()).pathname).toBe('/bingo'); +}); + +test('the back button closes the share screen and leaves the Bingo page open', async ({ page }) => { + await page.getByRole('button', { name: 'Share your card' }).click(); + const screen = page.getByRole('dialog', { name: 'Share your card' }); + await expect(screen).toBeVisible(); + await page.goBack(); + await expect(screen).toBeHidden(); + expect(new URL(page.url()).pathname).toBe('/bingo'); +}); + +test('where files cannot be shared, the button saves lvwwd-bingo.png and says so', async ({ + page, +}) => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true }); + }); + await page.getByRole('button', { name: 'Share your card' }).click(); + const screen = page.getByRole('dialog', { name: 'Share your card' }); + const save = screen.getByRole('button', { name: 'Save image' }); + await expect(save).toBeEnabled(); + const download = page.waitForEvent('download'); + await save.click(); + expect((await download).suggestedFilename()).toBe('lvwwd-bingo.png'); + await expect( + screen.getByText('Image saved. Look for lvwwd-bingo.png in your downloads.'), + ).toBeVisible(); +}); + +test('"Copy image description" copies the description and says so', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.getByRole('button', { name: 'Share your card' }).click(); + const screen = page.getByRole('dialog', { name: 'Share your card' }); + await screen.getByRole('button', { name: 'Copy image description' }).click(); + await expect(screen.getByText('Description copied.')).toBeVisible(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('No squares marked yet; the middle square is free.'); +}); + +test('the "Bingo!" message opens the share screen and focus comes back to the card', async ({ + page, +}) => { + await mark(page, ROW_THREE); + const message = page.getByRole('dialog', { name: 'Bingo!' }); + await expect(message).toBeVisible(); + await message.getByRole('button', { name: 'Share your card' }).click(); + await expect(message).toBeHidden(); + const screen = page.getByRole('dialog', { name: 'Share your card' }); + await expect(screen).toBeVisible(); + await screen.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByRole('button', { name: 'Share your card' })).toBeFocused(); +}); From 350cd3abe3ebf39900e95d55a5ae4dadb39d37dd Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 23 Sep 2026 17:37:05 -0700 Subject: [PATCH 3/5] feat(site): List partners from one roster, with links and QR codes Every partner now lives in one list, src/data/partners.ts. A volunteer adds an organization by adding one item there and publishing the site; the Partners page, the home page's partner wall and the QR code files all follow. The build checks each item and stops with a message naming the organization when a field is missing, a slug is malformed, taken or "general", a website doesn't start with https://, a type isn't one of the six, or a sentence runs past 200 characters. The Partners page lists partners by name, ignoring capitals and a leading "The", each with its name linking to its website, its type and its sentence, in one column on a phone and two from 768 pixels. With no partner yet, the roster is left out and the page opens with "Bring your organization". "Copy email address" now announces "Copied" to screen readers. From 12:00 am October 9, by the phone's clock, the roster heading reads "Took part in 2026" and the join section gives way to a note about next year. For each partner, and for LVBT's general materials, the build writes QR code files at /partners/qr/.png (1024 pixels square) and .svg, each opening the partner's giveaway link at error correction level M. A small PNG writer uses Node's own zlib. The logo wall, the logo request, the sample posts and the staff email leave the page: partners are listed by name only, and the partner kit in the next change replaces the banner mockups. A how-to explains how to add, change or remove a partner. Co-Authored-By: Claude Opus 5.5 --- apps/site/public/scripts/partners.js | 139 +++--- apps/site/src/data/partners.ts | 26 + apps/site/src/lib/partners.ts | 145 +++++- apps/site/src/lib/png.ts | 58 +++ apps/site/src/lib/wwd.ts | 9 +- apps/site/src/pages/index.astro | 2 +- apps/site/src/pages/partners.astro | 545 +++++---------------- apps/site/src/pages/partners/flyer.astro | 12 +- apps/site/src/pages/partners/qr/[file].ts | 33 ++ apps/site/tests/api-partner-credit.test.ts | 10 +- apps/site/tests/e2e/partners.spec.ts | 89 ++++ apps/site/tests/partners.test.ts | 139 ++++++ docs/README.md | 1 + docs/operations/how-to/add-a-partner.md | 58 +++ 14 files changed, 737 insertions(+), 529 deletions(-) create mode 100644 apps/site/src/data/partners.ts create mode 100644 apps/site/src/lib/png.ts create mode 100644 apps/site/src/pages/partners/qr/[file].ts create mode 100644 apps/site/tests/e2e/partners.spec.ts create mode 100644 apps/site/tests/partners.test.ts create mode 100644 docs/operations/how-to/add-a-partner.md diff --git a/apps/site/public/scripts/partners.js b/apps/site/public/scripts/partners.js index 4644771..e64c49e 100644 --- a/apps/site/public/scripts/partners.js +++ b/apps/site/public/scripts/partners.js @@ -1,83 +1,106 @@ /** - * Partners page copy buttons: "Copy email address" and "Copy snippet". - * Both are progressive enhancement over plain visible text, so they are - * `hidden` in markup and only shown once this script confirms it can run - * (see the bottom of this file). A button copies `data-copy-text` directly, - * or the text content of the element `data-copy-target` points at. + * The Partners page: its copy buttons, and its text after the week. * - * No backend involved: this only touches the clipboard and the DOM. + * A copy button copies `data-copy-text`, or the text (or a text box's + * value) of the element `data-copy-target` points at. It then reads + * "Copied" for 3 seconds, and its status line (the element its + * aria-describedby names, a polite live region) says "Copied" so screen + * readers hear it. If the browser refuses, the text is selected instead, + * with a note on how to copy it. The buttons in markup that are `hidden` + * are shown only once this script runs. + * + * From 12:00 am October 9, 2026, Las Vegas time, by the phone's clock, the + * roster heading reads "Took part in 2026" and "Bring your organization" + * gives way to a note about next year. */ (() => { + const ENDED = Date.parse('2026-10-09T07:00:00Z'); const REVERT_MS = 3000; const timers = new WeakMap(); - function revertLater(button, originalLabel) { - const existing = timers.get(button); - if (existing) window.clearTimeout(existing); - timers.set( - button, - window.setTimeout(() => { - button.textContent = originalLabel; - timers.delete(button); - }, REVERT_MS), - ); + function showEnded() { + const heading = document.querySelector('[data-partners-roster-heading]'); + if (heading) heading.textContent = 'Took part in 2026'; + const join = document.querySelector('[data-partners-join]'); + const ended = document.querySelector('[data-partners-ended]'); + if (join) join.hidden = true; + if (ended) ended.hidden = false; } async function copyText(text) { - if (navigator.clipboard && window.isSecureContext) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } + if (!navigator.clipboard || !window.isSecureContext) return false; + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; } - return false; } - function selectText(el) { + function select(el) { + if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) { + el.focus(); + el.select(); + return; + } const range = document.createRange(); range.selectNodeContents(el); const selection = window.getSelection(); - if (!selection) return; - selection.removeAllRanges(); - selection.addRange(range); + selection?.removeAllRanges(); + selection?.addRange(range); } - document.addEventListener('click', (event) => { - const target = event.target; - const button = - target instanceof Element ? target.closest('[data-copy-text], [data-copy-target]') : null; - if (!(button instanceof HTMLButtonElement)) return; - - const originalLabel = button.dataset.copyOriginalLabel ?? button.textContent ?? ''; - button.dataset.copyOriginalLabel = originalLabel; + function textOf(button) { + const selector = button.getAttribute('data-copy-target'); + const source = selector ? document.querySelector(selector) : null; + if (source instanceof HTMLTextAreaElement || source instanceof HTMLInputElement) { + return { source, text: source.value }; + } + if (source) return { source, text: source.textContent ?? '' }; + return { source: null, text: button.getAttribute('data-copy-text') ?? '' }; + } - const statusId = button.getAttribute('aria-describedby'); - const statusEl = statusId ? document.getElementById(statusId) : null; + function statusFor(button) { + const id = button.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; + } - const targetSelector = button.getAttribute('data-copy-target'); - const sourceEl = targetSelector ? document.querySelector(targetSelector) : null; - const text = sourceEl - ? (sourceEl.textContent ?? '') - : (button.getAttribute('data-copy-text') ?? ''); + async function copy(button) { + const { source, text } = textOf(button); if (!text) return; + const status = statusFor(button); + const label = button.querySelector('[data-copy-label]') ?? button; + button.dataset.copyLabel ??= label.textContent ?? ''; - copyText(text).then((ok) => { - if (ok) { - if (statusEl) statusEl.textContent = ''; - button.textContent = 'Copied'; - revertLater(button, originalLabel); - return; - } - if (sourceEl) selectText(sourceEl); - if (statusEl) { - statusEl.textContent = 'Press and hold, or use your keyboard, to copy the selected text.'; - } - }); - }); + if (await copyText(text)) { + label.textContent = 'Copied'; + if (status) status.textContent = 'Copied'; + window.clearTimeout(timers.get(button)); + timers.set( + button, + window.setTimeout(() => { + label.textContent = button.dataset.copyLabel ?? ''; + if (status?.textContent === 'Copied') status.textContent = ''; + }, REVERT_MS), + ); + return; + } + if (source) select(source); + if (status) { + status.textContent = 'Press and hold, or use your keyboard, to copy the selected text.'; + } + } - document.querySelectorAll('[data-copy-text], [data-copy-target]').forEach((el) => { - el.hidden = false; + document.addEventListener('click', (event) => { + const target = event.target instanceof Element ? event.target : null; + const button = target?.closest('[data-copy-text], [data-copy-target]'); + if (button instanceof HTMLButtonElement) void copy(button); }); + + document + .querySelectorAll('[data-copy-text][hidden], [data-copy-target][hidden]') + .forEach((el) => { + el.hidden = false; + }); + if (Date.now() >= ENDED) showEnded(); })(); diff --git a/apps/site/src/data/partners.ts b/apps/site/src/data/partners.ts new file mode 100644 index 0000000..c6ec0bf --- /dev/null +++ b/apps/site/src/data/partners.ts @@ -0,0 +1,26 @@ +import type { RosterItem } from '../lib/partners'; + +// The partner roster: every organization taking part in Week Without +// Driving Las Vegas 2026. It is the only place partner details live. The +// Partners page, each partner's link and QR code files, and the home page +// all read it, so adding a partner is adding one item here and publishing +// the site. docs/operations/how-to/add-a-partner.md walks through it. +// +// Add an organization only after it has confirmed its sentence and type by +// email. Once its link or QR code has been shared, never change its slug: +// printed codes and posted links carry it. +// +// Each item looks like this (a made-up partner, for the format only): +// +// { +// slug: 'example-club', +// name: 'Example Club', +// url: 'https://example.org', +// type: 'Student groups', +// sentence: 'Our members are taking the bus to class together on October 6.', +// }, +// +// `url` is optional. `type` is one of the six in PARTNER_TYPES in +// src/lib/partners.ts, spelled exactly. The build stops with a message +// naming the partner if an item breaks a rule. +export const roster: RosterItem[] = []; diff --git a/apps/site/src/lib/partners.ts b/apps/site/src/lib/partners.ts index 61a0731..4e6d0ea 100644 --- a/apps/site/src/lib/partners.ts +++ b/apps/site/src/lib/partners.ts @@ -1,34 +1,129 @@ -import { wwd } from './wwd'; - -// The partner roster, for the Partners page and for crediting sign-ups. -// It is driven by `wwd.partners` (apps/site/src/lib/wwd.ts), which stays -// an empty array until LVBT confirms its first partner by email. That -// array's declared shape only carries `name` and an optional `url`; a real -// roster entry also needs a slug (the short name in its `?ref=` -// link), a type and a one-sentence blurb. Rather than widen the shared -// `wwd.ts` type for a shape nothing uses yet, this file casts the (always -// empty, for now) array to the fuller shape a populated roster needs. When -// the roster gets its first real entry, `wwd.ts` should grow those fields -// directly and this cast can be dropped. -export type PartnerType = - | 'Community and neighborhood groups' - | 'Student groups' - | 'Environmental and justice groups' - | 'Disability and senior advocates' - | 'Employers and businesses' - | 'Public agencies'; - -export interface Partner { - name: string; +import { roster } from '../data/partners'; + +// Partners: the organizations bringing Week Without Driving to their +// members, and what the site makes for each of them. The roster itself is +// src/data/partners.ts; this file checks it, sorts it, and builds each +// partner's link and QR code addresses. + +export const PARTNER_TYPES = [ + 'Community and neighborhood groups', + 'Student groups', + 'Environmental and justice groups', + 'Disability and senior advocates', + 'Employers and businesses', + 'Public agencies', +] as const; + +export type PartnerType = (typeof PARTNER_TYPES)[number]; + +/** One organization, as a volunteer writes it in the roster. */ +export interface RosterItem { + /** Short, permanent name: lowercase letters, digits and single hyphens. */ slug: string; + /** The organization's name as it wants it shown. */ + name: string; + /** Its website or social media page, starting with https://. */ url?: string; - /** Logo image path or URL, once the partner has sent one in. */ - logoUrl?: string; type: PartnerType; + /** One sentence on how it is taking part, at most 200 characters. */ sentence: string; } -export const partners: Partner[] = wwd.partners as Partner[]; +export type Partner = RosterItem; + +/** The slug kept for LVBT's own materials, which no partner may use. */ +export const GENERAL = 'general'; + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_SLUG = 40; +const MAX_SENTENCE = 200; + +/** A roster item as written, before it is checked: any field may be missing or wrong. */ +export type UncheckedItem = { [Field in keyof RosterItem]?: unknown }; + +const text = (value: unknown): string => (typeof value === 'string' ? value : ''); + +function slugProblems(slug: string): string[] { + const problems: string[] = []; + if (!SLUG.test(slug)) { + problems.push( + `its slug "${slug}" must be lowercase letters and digits, with single hyphens between them`, + ); + } + if (slug.length > MAX_SLUG) problems.push(`its slug is longer than ${MAX_SLUG} characters`); + if (slug === GENERAL) problems.push(`the slug "${GENERAL}" is kept for LVBT's own materials`); + return problems; +} + +function problemsWith(item: UncheckedItem): string[] { + const missing = (['slug', 'name', 'type', 'sentence'] as const) + .filter((field) => text(item[field]).trim() === '') + .map((field) => `it has no ${field}`); + const problems = [...missing]; + const slug = text(item.slug); + if (slug) problems.push(...slugProblems(slug)); + if (item.url !== undefined && !text(item.url).startsWith('https://')) { + problems.push(`its url "${text(item.url)}" must start with https://`); + } + const type = text(item.type); + if (type && !(PARTNER_TYPES as readonly string[]).includes(type)) { + problems.push(`its type "${type}" must be one of: ${PARTNER_TYPES.join('; ')}`); + } + const sentence = text(item.sentence); + if (sentence.length > MAX_SENTENCE) { + problems.push(`its sentence is ${sentence.length} characters, more than ${MAX_SENTENCE}`); + } + return problems; +} + +/** + * Checks every roster item and throws one error listing each problem with + * the partner's name, so the build stops before a broken link or QR code + * is published. + */ +export function checkRoster(items: readonly UncheckedItem[]): Partner[] { + const problems: string[] = []; + const seen = new Map(); + items.forEach((item, index) => { + const name = text(item.name); + const who = name ? `"${name}"` : `item ${index + 1}`; + for (const problem of problemsWith(item)) problems.push(`${who}: ${problem}`); + const slug = text(item.slug); + if (!slug) return; + const other = seen.get(slug); + if (other) problems.push(`${who}: its slug "${slug}" is already used by ${other}`); + seen.set(slug, who); + }); + if (problems.length > 0) { + throw new Error( + `The partner roster (src/data/partners.ts) has problems:\n- ${problems.join('\n- ')}`, + ); + } + return items as Partner[]; +} + +/** The name a partner is sorted by: no capitals, and no leading "The ". */ +function sortName(name: string): string { + return name.replace(/^the\s+/i, '').toLocaleLowerCase('en-US'); +} + +/** Partners in alphabetical order of name, ignoring capitals and a leading "The ". */ +export function sortPartners(items: readonly Partner[]): Partner[] { + return [...items].sort((a, b) => sortName(a.name).localeCompare(sortName(b.name), 'en-US')); +} + +/** The giveaway link that credits sign-ups to a partner, or LVBT's general one. */ +export function partnerLink(slug: string): string { + return slug === GENERAL ? 'https://lvwwd.org/giveaway' : `https://lvwwd.org/giveaway?ref=${slug}`; +} + +/** Where a partner's (or the general) QR code files are published. */ +export function qrFiles(slug: string): { png: string; svg: string } { + return { png: `/partners/qr/${slug}.png`, svg: `/partners/qr/${slug}.svg` }; +} + +/** Every partner, checked and sorted. Empty until the first partner confirms. */ +export const partners: Partner[] = sortPartners(checkRoster(roster)); /** * The partner a `?ref=` link names, or null when it names none on the diff --git a/apps/site/src/lib/png.ts b/apps/site/src/lib/png.ts new file mode 100644 index 0000000..7556542 --- /dev/null +++ b/apps/site/src/lib/png.ts @@ -0,0 +1,58 @@ +import { crc32, deflateSync } from 'node:zlib'; + +import { QUIET_ZONE, type QrCode } from './qrcode'; + +// Writes a QR code as a black-and-white PNG when the site is built, with +// Node's own zlib, so no image library is needed. One bit per pixel keeps +// the 1024-pixel files to a few kilobytes. + +function chunk(type: string, data: Uint8Array): Uint8Array { + const body = new Uint8Array(4 + data.length); + body.set(new TextEncoder().encode(type), 0); + body.set(data, 4); + const out = new Uint8Array(12 + data.length); + const view = new DataView(out.buffer); + view.setUint32(0, data.length); + out.set(body, 4); + view.setUint32(8 + data.length, crc32(body)); + return out; +} + +/** + * The code as a square PNG `pixels` wide, black modules on white, with its + * four-module quiet zone. Each module covers a whole number of pixels, give + * or take one, so the code stays sharp at any printed size. + */ +export function qrPng(qr: QrCode, pixels = 1024): Uint8Array { + const modules = qr.size + QUIET_ZONE * 2; + const rowBytes = Math.ceil(pixels / 8); + const raw = new Uint8Array((rowBytes + 1) * pixels); + const moduleAt = (p: number) => Math.floor((p * modules) / pixels) - QUIET_ZONE; + for (let y = 0; y < pixels; y++) { + const row = qr.matrix[moduleAt(y)]; + const start = y * (rowBytes + 1) + 1; // each row starts with filter type 0 + for (let x = 0; x < pixels; x++) { + const dark = row?.[moduleAt(x)] === true; + // In one-bit grayscale, 1 is white. + if (!dark) raw[start + (x >> 3)] = (raw[start + (x >> 3)] ?? 0) | (0x80 >> (x & 7)); + } + } + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, pixels); + view.setUint32(4, pixels); + header.set([1, 0, 0, 0, 0], 8); // bit depth 1, grayscale, no interlace + const parts = [ + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', new Uint8Array(0)), + ]; + const file = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { + file.set(part, offset); + offset += part.length; + } + return file; +} diff --git a/apps/site/src/lib/wwd.ts b/apps/site/src/lib/wwd.ts index 54ae153..49fdafc 100644 --- a/apps/site/src/lib/wwd.ts +++ b/apps/site/src/lib/wwd.ts @@ -1,6 +1,6 @@ -// Week Without Driving campaign data. One place for the dates, hashtag, -// giveaway rules, and partner roster that /wwd renders, so the yearly -// update is a few values here rather than a copy-edit of the page. Dates +// Week Without Driving campaign data. One place for the dates, hashtag and +// giveaway rules, so the yearly update is a few values here rather than a +// copy-edit of the pages. The partner roster is src/data/partners.ts. Dates // come from weekwithoutdriving.org and change every year. export const wwd = { year: 2026, @@ -15,9 +15,6 @@ export const wwd = { rtcTripPlannerUrl: 'https://www.rtcsnv.com/ways-to-travel/trip-planner/', hashtag: '#WeekWithoutDriving', maxEntries: 8, - // Organizations that have committed to take part. Rendered as a roster - // once non-empty; hidden until then so the page never shows an empty box. - partners: [] as Array<{ name: string; url?: string }>, } as const; // Links out to the national campaign and local tools, grouped by who they diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 1bc0610..7b59079 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -14,7 +14,7 @@ import { partners } from '../lib/partners'; // it is, and one button. Below that, the three steps, why it matters, the // prizes, the daily Instagram hosts, the partners and the local leaders. // Hosts, partners and proclamations fill in from src/lib/campaign.ts and -// src/lib/partners.ts as LVBT confirms them. +// src/data/partners.ts as LVBT confirms them. const dateRange = `October 1 to 8, ${wwd.year}`; const canonical = `${site.url}/`; diff --git a/apps/site/src/pages/partners.astro b/apps/site/src/pages/partners.astro index 55d6e23..a2c78e6 100644 --- a/apps/site/src/pages/partners.astro +++ b/apps/site/src/pages/partners.astro @@ -1,66 +1,22 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import { Icon } from 'astro-icon/components'; -import { wwd, WWD_DATE_RANGE } from '../lib/wwd'; -import { site, lvbt } from '../lib/site'; -import { handleFromUrl } from '../lib/social'; import { partners, contactEmail, joinMailto } from '../lib/partners'; -// Partners page: the roster (from "Show the organizations taking part on -// the Partners page"), the "Bring your organization" invitation, a -// simplified banners-and-embed section, and a link to the printable flyer -// at /partners/flyer. The full partner kit — an organization picker, per- -// partner QR codes and download buttons for real banner image files — is -// out of scope until the roster has its first confirmed partner; see the -// build report for the decisions made to reach this simplified version. +// Partners, /partners: the organizations taking part, and how another +// group can join. Every partner comes from the roster in +// src/data/partners.ts; with none yet, the roster is left out entirely and +// the page opens with "Bring your organization". +// +// After the week ends (12:00 am October 9, 2026, Las Vegas time, by the +// phone's clock), /scripts/partners.js changes the roster heading to "Took +// part in 2026" and swaps "Bring your organization" for a note about next +// year. Without JavaScript the page keeps its during-the-week text. const title = 'Partners · Week Without Driving Las Vegas'; const description = 'Organizations across the Las Vegas Valley taking part in Week Without Driving, and how your group can join.'; -const roster = partners.slice().sort((a, b) => a.name.localeCompare(b.name)); -const hasRoster = roster.length > 0; - -// The week ends 2026-10-09T07:00:00Z (12:00 am October 9, Las Vegas time); -// `wwd.end` already carries that instant. -const campaignOver = Date.now() > wwd.end.getTime(); - -// Groups taking part: a logo wall, not the detailed writeup below it. It -// appears once the roster has its first confirmed partner. - -const siteHost = site.url.replace(/^https?:\/\//, ''); -const igHandle = handleFromUrl(lvbt.instagramUrl) ?? '@lasvegasfortransit'; -const dateRangeWithYear = `${WWD_DATE_RANGE}, ${wwd.year}`; - -// Partner toolkit — sample social posts, ready to copy. Each includes the -// hashtag, the Instagram handle, and the site so a partner can post it -// as-is. Copy behavior reuses public/scripts/partners.js's existing -// data-copy-target handling. -const samplePosts = [ - `We're taking part in Week Without Driving, ${dateRangeWithYear}. Get around by bus, walking or rolling, biking, or getting a ride. Sign up to win a free month of bus rides at ${siteHost}. Tag ${igHandle} and use ${wwd.hashtag}.`, - `How would you get around Las Vegas without a car? Find out with us during Week Without Driving, ${dateRangeWithYear}. Sign up to win a free month of bus rides at ${siteHost}. ${wwd.hashtag} ${igHandle}`, - `Anyone can take part: Week Without Driving is ${dateRangeWithYear} in Las Vegas. Join us and sign up to win at ${siteHost}. ${wwd.hashtag} ${igHandle}`, -]; - -const staffEmailSubject = `Join us for Week Without Driving, ${dateRangeWithYear}`; -const staffEmailBody = [ - 'Hi everyone,', - '', - `Our group is taking part in Week Without Driving, ${dateRangeWithYear}. Try getting around without driving: by bus, walking or rolling, biking, or getting a ride. Anyone can take part, no sign-up needed.`, - '', - `Sign up to win a free month of bus rides at ${siteHost}.`, - '', - 'Thanks,', - '[Your name]', -].join('\n'); -const staffEmailTemplate = `Subject: ${staffEmailSubject}\n\n${staffEmailBody}`; - -const logoMailto = `mailto:${contactEmail}?subject=${encodeURIComponent( - 'Partner logo: [your group]', -)}&body=${encodeURIComponent( - ['Group name:', 'Website:', 'Logo attached as PNG or SVG? (yes/no):'].join('\n'), -)}`; - const weWantToHear = [ 'Disability and senior advocates', 'Student groups and school clubs', @@ -76,106 +32,26 @@ const whatPartnersDo = [ "Co-host a group walk, roll or ride during the week. We'll help plan the route.", 'Hand out flyers or paper bingo cards at your meetings and events.', ]; - -// Three of the four fixed banner sizes from "Give each partner banners, a -// link and an embed snippet" — the build brief asks for three simple -// mockups rather than the full four-banner, per-partner kit. -const banners = [ - { - name: 'Square post', - dims: '1080 × 1080', - purpose: 'For Instagram and Facebook posts.', - aspect: '1 / 1', - }, - { - name: 'Story', - dims: '1080 × 1920', - purpose: 'For Instagram and Facebook stories and WhatsApp status.', - aspect: '9 / 16', - }, - { - name: 'Website box', - dims: '300 × 250', - purpose: 'For a website sidebar or newsletter.', - aspect: '300 / 250', - }, -]; - -const embedSnippet = ` - Week Without Driving Las Vegas, October 1 to 8, 2026. Get around by bus, walking or rolling, biking, or getting a ride. Sign up to win at lvwwd.org. -`; --- - + diff --git a/apps/site/src/lib/newsletter.ts b/apps/site/src/lib/newsletter.ts new file mode 100644 index 0000000..8d99c79 --- /dev/null +++ b/apps/site/src/lib/newsletter.ts @@ -0,0 +1,27 @@ +// LVBT's newsletter, as the "Keep going after the week" card on My week and +// Home's "Stay involved with LVBT" section offer it. + +/** + * Whether the card can add someone to LVBT's newsletter in one tap, through + * the Worker's POST /api/newsletter. That route, and the Beehiiv key it + * needs in production, are not built yet, so the card links to LVBT's own + * newsletter page instead; the one-tap sign-up is built and tested, and + * turning this on is the only change the card needs once the route works. + */ +export const ONE_TAP_NEWSLETTER = false; + +/** LVBT's newsletter sign-up page, for anyone the one-tap sign-up can't serve. */ +export const NEWSLETTER_PAGE = 'https://mail.lasvegasfortransit.org/'; + +/** 12:00 am October 9, 2026, Las Vegas time: the giveaway closes and Home changes. */ +export const GIVEAWAY_CLOSES = '2026-10-09T07:00:00Z'; + +export const KEEP_GOING = { + heading: 'Keep going after the week', + text: 'Week Without Driving lasts eight days, but Las Vegans for Better Transit works all year for better buses and safer streets across the valley. Keep going with us.', +} as const; + +export const STAY_INVOLVED = { + heading: 'Stay involved with LVBT', + text: 'Week Without Driving 2026 is over. Thank you for taking part. Keep going after the week with Las Vegans for Better Transit: we work all year for better buses and safer streets across the valley.', +} as const; diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 7b59079..8f846fb 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -9,6 +9,8 @@ import { lvbt, site } from '../lib/site'; import { wwd } from '../lib/wwd'; import { days, prizes, proclamations } from '../lib/campaign'; import { partners } from '../lib/partners'; +import KeepGoingCard from '../components/KeepGoingCard.astro'; +import { STAY_INVOLVED } from '../lib/newsletter'; // The Home page, for someone who has five seconds: what the week is, when // it is, and one button. Below that, the three steps, why it matters, the @@ -120,10 +122,21 @@ const leaderMailto = `mailto:wwd@lasvegasfortransit.org?subject=${encodeURICompo Take the bus, walk, bike or get a ride. Anyone can take part. Sign up with us to win a free month of bus rides.

+ {/* From October 9, /scripts/keep-going.js swaps "Sign up to win" for these. */} + @@ -308,11 +321,23 @@ const leaderMailto = `mailto:wwd@lasvegasfortransit.org?subject=${encodeURICompo + {/* STAY INVOLVED: from October 9, in place of the last call to sign up. */} + + {/* LAST CALL */}
-

Try it this October.

- +

Try it this October.

+
Sign up to win

@@ -320,9 +345,14 @@ const leaderMailto = `mailto:wwd@lasvegasfortransit.org?subject=${encodeURICompo

+