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/partners/banners/1080x1080.png b/apps/site/public/partners/banners/1080x1080.png new file mode 100644 index 0000000..4b4fb7d Binary files /dev/null and b/apps/site/public/partners/banners/1080x1080.png differ diff --git a/apps/site/public/partners/banners/1080x1920.png b/apps/site/public/partners/banners/1080x1920.png new file mode 100644 index 0000000..4962a5a Binary files /dev/null and b/apps/site/public/partners/banners/1080x1920.png differ diff --git a/apps/site/public/partners/banners/300x250.png b/apps/site/public/partners/banners/300x250.png new file mode 100644 index 0000000..db366ea Binary files /dev/null and b/apps/site/public/partners/banners/300x250.png differ diff --git a/apps/site/public/partners/banners/728x90.png b/apps/site/public/partners/banners/728x90.png new file mode 100644 index 0000000..e45acb3 Binary files /dev/null and b/apps/site/public/partners/banners/728x90.png differ diff --git a/apps/site/public/scripts/bingo-picture.js b/apps/site/public/scripts/bingo-picture.js new file mode 100644 index 0000000..1649670 --- /dev/null +++ b/apps/site/public/scripts/bingo-picture.js @@ -0,0 +1,300 @@ +/** + * The bingo card picture: a 1080 by 1920 PNG of the player's card, the size + * of a phone story, with its written description for screen readers. + * + * window.lvwwdBingoPicture.make(marks, squares) returns a picture + * definition for the share screen (/scripts/share-screen.js): + * { heading, fileName, width, height, choices, note, draw(canvas), describe() }. + * `marks` is the card's 25 true or false values in card order (row 1 left + * to right, then row 2, and so on; index 12 is the free square), and + * `squares` is the card's 25 squares, each { label, free }. + * + * Everything happens on the phone: nothing is fetched or stored, and the + * picture never shows a name or any other personal detail. + */ +(() => { + const WIDTH = 1080; + const HEIGHT = 1920; + const FREE = 12; + const COLORS = { + background: '#0F1115', + paper: '#F7F4EC', + ember: '#FF8A5C', + marked: '#B8360F', + tile: '#FFFFFF', + ink: '#0F1115', + }; + // Every text on the picture and the color behind it, for the contrast check. + const TEXT_PAIRS = [ + ['heading, campaign line and hashtag', COLORS.paper, COLORS.background], + ['count line', COLORS.ember, COLORS.background], + ['unmarked square', COLORS.ink, COLORS.tile], + ['marked square', COLORS.tile, COLORS.marked], + ['free square', COLORS.ink, COLORS.ember], + ]; + const HEADING_FONT = 'Fraunces, Georgia, serif'; + const BODY_FONT = '"Atkinson Hyperlegible Next", system-ui, sans-serif'; + + const GRID = { top: 480, left: 60, tile: 188, gap: 5 }; + const LABEL = { top: 56, bottom: 10, side: 12, size: 28, min: 20, lines: 4, leading: 1.15 }; + // The free square's words sit under its star. + const FREE_LABEL_TOP = 76; + + const LINE_NAMES = [ + 'row 1', + 'row 2', + 'row 3', + 'row 4', + 'row 5', + 'column 1', + 'column 2', + 'column 3', + 'column 4', + 'column 5', + 'the diagonal from top left', + 'the diagonal from top right', + ]; + const LINES = []; + for (let r = 0; r < 5; r++) LINES.push([0, 1, 2, 3, 4].map((c) => r * 5 + c)); + for (let c = 0; c < 5; c++) LINES.push([0, 1, 2, 3, 4].map((r) => r * 5 + c)); + LINES.push([0, 6, 12, 18, 24], [4, 8, 12, 16, 20]); + + /** The marks with the free square always marked. */ + function normalize(marks) { + return Array.from({ length: 25 }, (_, i) => i === FREE || Boolean(marks[i])); + } + + function tally(marks) { + const card = normalize(marks); + const marked = card.filter((on, i) => on && i !== FREE).length; + const lines = LINES.map((line, i) => (line.every((s) => card[s]) ? i : -1)).filter( + (i) => i >= 0, + ); + return { card, marked, lines }; + } + + const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; + + function countLine(marks) { + const { marked, lines } = tally(marks); + if (marked === 0) return 'Ready to play'; + if (marked === 24) return 'All 24 squares · 12 bingos'; + const squares = plural(marked, 'square'); + return lines.length === 0 ? squares : `${squares} · ${plural(lines.length, 'bingo')}`; + } + + function joinWithAnd(items) { + if (items.length < 2) return items.join(''); + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; + } + + function describe(marks, squares) { + const { card, marked, lines } = tally(marks); + const parts = [ + 'Graphic: my Digital Transit Bingo card for Week Without Driving Las Vegas, October 1 to 8, 2026.', + ]; + if (marked === 0) { + parts.push('No squares marked yet; the middle square is free.'); + } else if (marked === 24) { + parts.push('All 24 squares marked and 12 bingos.'); + } else { + const squaresMarked = `${plural(marked, 'square')} marked`; + parts.push( + lines.length === 0 + ? `${squaresMarked}, no bingo yet.` + : `${squaresMarked} and ${plural(lines.length, 'bingo')}.`, + ); + const labels = squares + .map((square, i) => (card[i] && i !== FREE ? square.label : null)) + .filter(Boolean); + parts.push(`Marked: ${labels.join(', ')}.`); + if (lines.length > 0) + parts.push(`Bingo lines: ${joinWithAnd(lines.map((i) => LINE_NAMES[i]))}.`); + } + parts.push('lvwwd.org. #WeekWithoutDriving'); + return parts.join(' '); + } + + function wrap(measure, text, maxWidth) { + const lines = []; + let line = ''; + for (const word of text.split(' ')) { + const next = line ? `${line} ${word}` : word; + if (line && measure(next) > maxWidth) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) lines.push(line); + return lines; + } + + /** + * The largest size, from 28 down to 20 pixels, at which a label wraps + * between words onto at most four lines inside its tile. `measure(text, + * size)` returns the text's width in pixels at that size; `top` is where + * the first line starts, in pixels from the top of the tile. + */ + function fitLabel(measure, text, top = LABEL.top) { + const maxWidth = GRID.tile - LABEL.side * 2; + const maxHeight = GRID.tile - top - LABEL.bottom; + let size = LABEL.size; + for (; size >= LABEL.min; size -= 2) { + const width = (t) => measure(t, size); + const lines = wrap(width, text, maxWidth); + const fits = + lines.length <= LABEL.lines && + lines.every((line) => width(line) <= maxWidth) && + lines.length * size * LABEL.leading <= maxHeight; + if (fits) return { size, lines, lineHeight: size * LABEL.leading }; + } + size = LABEL.min; + return { + size, + lines: wrap((t) => measure(t, size), text, maxWidth), + lineHeight: size * LABEL.leading, + }; + } + + function tileBox(i) { + const step = GRID.tile + GRID.gap; + return { + x: GRID.left + (i % 5) * step, + y: GRID.top + Math.floor(i / 5) * step, + size: GRID.tile, + }; + } + + function check(c, box) { + const s = 48; + const x = box.x + box.size - 12 - s; + const y = box.y + 12; + c.strokeStyle = COLORS.tile; + c.lineWidth = 9; + c.lineCap = 'round'; + c.lineJoin = 'round'; + // Drawn in the upper part of its 48-pixel box, clear of the label below. + c.beginPath(); + c.moveTo(x + s * 0.1, y + s * 0.42); + c.lineTo(x + s * 0.38, y + s * 0.7); + c.lineTo(x + s * 0.92, y + s * 0.08); + c.stroke(); + } + + function star(c, cx, cy, outer) { + const inner = outer * 0.45; + c.fillStyle = COLORS.ink; + c.beginPath(); + for (let i = 0; i < 10; i++) { + const radius = i % 2 === 0 ? outer : inner; + const angle = -Math.PI / 2 + (i * Math.PI) / 5; + c.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)); + } + c.closePath(); + c.fill(); + } + + /** Draws a label centered in its tile: { text, color, weight, top }. */ + function drawLabel(c, box, label) { + const { text, color, weight, top } = label; + const fit = fitLabel( + (t, size) => { + c.font = `${weight} ${size}px ${BODY_FONT}`; + return c.measureText(t).width; + }, + text, + top, + ); + c.font = `${weight} ${fit.size}px ${BODY_FONT}`; + c.fillStyle = color; + fit.lines.forEach((line, n) => { + c.fillText(line, box.x + box.size / 2, box.y + top + n * fit.lineHeight); + }); + } + + /** Draws one tile: `tile` is { index, label, marked, inLine }. */ + function drawTile(c, tile) { + const box = tileBox(tile.index); + if (tile.index === FREE) { + c.fillStyle = COLORS.ember; + c.fillRect(box.x, box.y, box.size, box.size); + star(c, box.x + box.size / 2, box.y + 40, 28); + drawLabel(c, box, { text: tile.label, color: COLORS.ink, weight: 700, top: FREE_LABEL_TOP }); + } else if (tile.marked) { + c.fillStyle = COLORS.marked; + c.fillRect(box.x, box.y, box.size, box.size); + drawLabel(c, box, { text: tile.label, color: COLORS.tile, weight: 400, top: LABEL.top }); + check(c, box); + } else { + c.fillStyle = COLORS.tile; + c.fillRect(box.x, box.y, box.size, box.size); + c.strokeStyle = COLORS.ink; + c.lineWidth = 2; + c.strokeRect(box.x + 1, box.y + 1, box.size - 2, box.size - 2); + drawLabel(c, box, { text: tile.label, color: COLORS.ink, weight: 400, top: LABEL.top }); + } + if (tile.inLine) { + c.strokeStyle = COLORS.ember; + c.lineWidth = 8; + c.strokeRect(box.x + 4, box.y + 4, box.size - 8, box.size - 8); + } + } + + function draw(canvas, marks, squares) { + const c = canvas.getContext('2d'); + if (!c) return; + const { card, lines } = tally(marks); + const inLine = new Set(lines.flatMap((i) => LINES[i])); + + c.fillStyle = COLORS.background; + c.fillRect(0, 0, WIDTH, HEIGHT); + c.textAlign = 'center'; + c.textBaseline = 'top'; + + c.fillStyle = COLORS.paper; + c.font = `600 80px ${HEADING_FONT}`; + c.fillText('Digital Transit Bingo', WIDTH / 2, 280); + c.fillStyle = COLORS.ember; + c.font = `400 56px ${BODY_FONT}`; + c.fillText(countLine(marks), WIDTH / 2, 390); + + squares.forEach((square, index) => + drawTile(c, { index, label: square.label, marked: card[index], inLine: inLine.has(index) }), + ); + + c.fillStyle = COLORS.paper; + c.font = `400 44px ${BODY_FONT}`; + c.fillText('Week Without Driving Las Vegas ·', WIDTH / 2, 1480); + c.fillText('October 1 to 8, 2026 · lvwwd.org', WIDTH / 2, 1536); + c.font = `700 44px ${BODY_FONT}`; + c.fillText('#WeekWithoutDriving', WIDTH / 2, 1600); + } + + function make(marks, squares) { + const card = normalize(marks); + return { + heading: 'Share your card', + fileName: 'lvwwd-bingo.png', + width: WIDTH, + height: HEIGHT, + choices: null, + note: "A bingo card doesn't count as a trip. To enter, share a trip on My week.", + fonts: [`600 80px ${HEADING_FONT}`, `400 28px ${BODY_FONT}`, `700 28px ${BODY_FONT}`], + draw: (canvas) => draw(canvas, card, squares), + describe: () => describe(card, squares), + }; + } + + window.lvwwdBingoPicture = { + make, + countLine, + describe, + fitLabel, + tileBox, + COLORS, + TEXT_PAIRS, + FREE_LABEL_TOP, + }; +})(); 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..e6885b0 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 opening the share screen with the card's picture. +// 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,6 +73,15 @@ 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]')); + // The squares' short labels, for the share picture, written into the page + // from src/lib/bingo.ts as a JSON data block. + const squares = (() => { + try { + return JSON.parse(document.getElementById('bingo-squares')?.textContent ?? '[]'); + } catch { + return []; + } + })(); // --- storage ------------------------------------------------------- function checkStorageAvailable() { @@ -212,6 +226,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) { @@ -254,36 +273,16 @@ } // --- share ---------------------------------------------------------- - async function tryNativeShare(shareData) { - if (!navigator.share) return false; - try { - await navigator.share(shareData); - return true; - } catch { - return false; // Also covers a canceled share. - } - } - - async function tryClipboardShare(url) { - if (!navigator.clipboard || !navigator.clipboard.writeText) return false; - try { - await navigator.clipboard.writeText(url); - window.alert('Link copied. Paste it anywhere to share your card.'); - return true; - } catch { - return false; - } - } - - async function shareCard() { - const shareData = { - title: 'Digital Transit Bingo · Week Without Driving Las Vegas', - text: 'I’m playing Digital Transit Bingo for Week Without Driving Las Vegas.', - url: window.location.href, - }; - if (await tryNativeShare(shareData)) return; - if (await tryClipboardShare(shareData.url)) return; - window.prompt('Copy this link to share your card:', shareData.url); + // Opens the share screen (/scripts/share-screen.js) with a picture of the + // card as it is now (/scripts/bingo-picture.js). Focus comes back to the + // "Share your card" button below the card, even when the "Bingo!" + // message's own button opened it. + function shareCard() { + const picture = window.lvwwdBingoPicture; + const screen = window.lvwwdShareScreen; + if (!picture || !screen || squares.length !== SQUARE_COUNT) return; + closeLineDialog(); + screen.open(picture.make(state, squares), shareBtn); } shareBtn?.addEventListener('click', shareCard); diff --git a/apps/site/public/scripts/keep-going.js b/apps/site/public/scripts/keep-going.js new file mode 100644 index 0000000..df9a50a --- /dev/null +++ b/apps/site/public/scripts/keep-going.js @@ -0,0 +1,226 @@ +/** + * "Keep going after the week" on My week and "Stay involved with LVBT" on + * Home (src/components/KeepGoingCard.astro), and Home's switch once the + * giveaway closes. + * + * The card always links to LVBT's newsletter page. When the one-tap + * sign-up is on (data-one-tap="on"), this script swaps that link for "Join + * the newsletter": on My week, with the signed-in participant's own + * address when their contact is an email, from the GET /api/me that + * /scripts/my-week.js already made; otherwise, and on Home, with an email + * field. A tap asks Turnstile for a token, then sends POST /api/newsletter. + * + * On Home, from 12:00 am October 9, 2026, Las Vegas time, by the phone's + * clock, a visitor who isn't signed in sees the ended message, the "Stay + * involved with LVBT" button and section, and the closing band without its + * heading and "Sign up to win" button. + */ +(() => { + const CLOSES = Date.parse('2026-10-09T07:00:00Z'); + const TOKEN_WAIT_MS = 10_000; + const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; + const SOMETHING_WRONG = 'Something went wrong. Please try again.'; + const MESSAGES = { + empty: 'Enter your email address.', + invalid: 'Enter a full email address, like name@example.com.', + ok: "Almost done! Check your email for a message from Las Vegans for Better Transit, and tap Confirm my subscription. Already subscribed? You're all set.", + rate_limited: 'Too many tries. Wait a minute, then try again.', + verification_failed: "We couldn't confirm you're a person. Reload the page and try again.", + newsletter_unavailable: + "LVBT's newsletter service isn't answering right now. Please try again in a few minutes.", + unreachable: + "We couldn't reach the server. Check your connection and tap Join the newsletter again.", + }; + + /** The field message for a typed address, or '' when it is fine. */ + function checkEmail(value) { + const email = value.trim(); + if (email === '') return MESSAGES.empty; + return email.length <= 254 && EMAIL.test(email) ? '' : MESSAGES.invalid; + } + + /** The message for the route's reply: status 0 means no reply at all. */ + function messageFor(status, error) { + if (status === 0) return MESSAGES.unreachable; + if (status >= 200 && status < 300) return MESSAGES.ok; + if (error === 'invalid_email') return MESSAGES.invalid; + return MESSAGES[error] ?? SOMETHING_WRONG; + } + + window.lvwwdKeepGoing = { checkEmail, messageFor, CLOSES }; + if (typeof document === 'undefined') return; + + const signedIn = /(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie); + + function switchHome() { + const home = document.querySelector('[data-home-closed]'); + if (!home || signedIn || Date.now() < CLOSES) return; + document.querySelectorAll('[data-home-open]').forEach((el) => { + el.hidden = true; + }); + document.querySelectorAll('[data-home-closed]').forEach((el) => { + el.hidden = false; + }); + const section = document.getElementById('stay-involved'); + document.querySelector('[data-stay-involved-link]')?.addEventListener('click', (event) => { + if (!section) return; + event.preventDefault(); + section.scrollIntoView(); + section.querySelector('h2')?.focus({ preventScroll: true }); + window.history.replaceState(null, '', '#stay-involved'); + }); + } + + /** window.lvwwdApi (participant-api.js), loaded when first needed. */ + function participantApi() { + if (window.lvwwdApi) return Promise.resolve(window.lvwwdApi); + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = '/scripts/participant-api.js'; + script.addEventListener('load', () => resolve(window.lvwwdApi)); + script.addEventListener('error', () => reject(new Error('unreachable'))); + document.head.append(script); + }); + } + + function tokenWithin(bot) { + const late = new Promise((_resolve, reject) => { + window.setTimeout(() => reject(new Error('late')), TOKEN_WAIT_MS); + }); + return Promise.race([bot.token(), late]); + } + + async function send(body) { + try { + const response = await fetch('/api/newsletter', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => ({})); + return { status: response.status, error: data.error }; + } catch { + return { status: 0, error: '' }; + } + } + + /** The card's parts, and what it is doing: `known` means it uses the participant's own address. */ + function cardState(card) { + const part = (name) => card.querySelector(`[data-kg-${name}]`); + return { + card, + form: part('form'), + join: part('join'), + email: part('email'), + emailError: part('email-error'), + message: part('message'), + knownLine: part('known'), + address: part('address'), + field: part('field'), + other: part('other'), + offline: part('offline'), + source: card.dataset.source, + known: false, + sending: false, + bot: null, + }; + } + + function showField(ui, focus) { + ui.known = false; + if (ui.knownLine) ui.knownLine.hidden = true; + if (ui.other) ui.other.hidden = true; + if (ui.field) ui.field.hidden = false; + if (focus) ui.email.focus(); + } + + function showKnown(ui, address) { + ui.known = true; + if (ui.address) ui.address.textContent = address; + if (ui.knownLine) ui.knownLine.hidden = false; + if (ui.other) ui.other.hidden = false; + if (ui.field) ui.field.hidden = true; + } + + function fieldMessage(ui, text) { + if (ui.emailError) ui.emailError.textContent = text; + ui.email.setAttribute('aria-invalid', text ? 'true' : 'false'); + if (text) ui.email.focus(); + } + + /** Home only: a notice and a disabled-looking button while the phone is offline. */ + function showConnection(ui) { + const isOffline = navigator.onLine === false; + if (ui.offline) ui.offline.hidden = !isOffline; + ui.join.setAttribute('aria-disabled', String(isOffline)); + } + + function finish(ui, text, success) { + ui.sending = false; + ui.join.disabled = false; + ui.join.textContent = 'Join the newsletter'; + if (ui.message) ui.message.textContent = text; + if (success) ui.form.hidden = true; + else ui.bot?.reset(); + } + + async function submit(ui, event) { + event.preventDefault(); + if (ui.sending || ui.join.getAttribute('aria-disabled') === 'true') return; + if (ui.message) ui.message.textContent = ''; + const body = { source: ui.source }; + if (!ui.known) { + const problem = checkEmail(ui.email.value); + fieldMessage(ui, problem); + if (problem) return; + body.email = ui.email.value.trim(); + } + ui.sending = true; + ui.join.disabled = true; + ui.join.textContent = 'Joining…'; + try { + const api = await participantApi(); + ui.bot ??= api.botCheck(ui.card.querySelector('[data-turnstile]'), 'newsletter'); + body.turnstileToken = await tokenWithin(ui.bot); + } catch { + finish(ui, SOMETHING_WRONG, false); + return; + } + const reply = await send(body); + if (reply.error === 'invalid_email' && !ui.known) { + finish(ui, '', false); + fieldMessage(ui, MESSAGES.invalid); + return; + } + finish(ui, messageFor(reply.status, reply.error), reply.status >= 200 && reply.status < 300); + } + + /** Swaps the newsletter page link for the one-tap sign-up. `me` is My week's participant. */ + function start(ui, me) { + ui.card.querySelector('[data-kg-page-link]')?.setAttribute('hidden', ''); + ui.form.hidden = false; + if (me?.contactType === 'email' && me.contactMasked) showKnown(ui, me.contactMasked); + else showField(ui, false); + } + + function setUpCard(card) { + const ui = cardState(card); + if (!ui.form || !ui.join || !ui.email) return; + ui.form.addEventListener('submit', (event) => void submit(ui, event)); + ui.other?.addEventListener('click', () => showField(ui, true)); + if (ui.source === 'home') { + window.addEventListener('online', () => showConnection(ui)); + window.addEventListener('offline', () => showConnection(ui)); + showConnection(ui); + start(ui, null); + } else if (window.lvwwdMe) { + start(ui, window.lvwwdMe); + } else { + document.addEventListener('lvwwd:me', (event) => start(ui, event.detail), { once: true }); + } + } + + switchHome(); + document.querySelectorAll('[data-keep-going][data-one-tap="on"]').forEach(setUpCard); +})(); diff --git a/apps/site/public/scripts/my-week.js b/apps/site/public/scripts/my-week.js index 05c9ac1..ceadc1f 100644 --- a/apps/site/public/scripts/my-week.js +++ b/apps/site/public/scripts/my-week.js @@ -310,6 +310,9 @@ renderBanners(); renderEntries(); inside.hidden = false; + // For the "Keep going after the week" card (/scripts/keep-going.js). + window.lvwwdMe = me; + document.dispatchEvent(new CustomEvent('lvwwd:me', { detail: me })); bindTrip(); bindReminders(); bindSignOut(); diff --git a/apps/site/public/scripts/partner-kit.js b/apps/site/public/scripts/partner-kit.js new file mode 100644 index 0000000..a98a02c --- /dev/null +++ b/apps/site/public/scripts/partner-kit.js @@ -0,0 +1,109 @@ +/** + * The partner kit on the Partners page. Picking an organization shows its + * giveaway link, its QR code files, the banners and an embed snippet that + * links to it, and puts #kit- in the page address so the choice can + * be bookmarked or sent to a colleague. "None (general materials)" gives + * LVBT's own link, which credits no one. + * + * From 12:00 am October 9, 2026, Las Vegas time, by the phone's clock, the + * kit shows only its closing message. The copy buttons are run by + * /scripts/partners.js. Nothing is fetched or stored. + */ +(() => { + const ENDED = Date.parse('2026-10-09T07:00:00Z'); + const GENERAL = 'general'; + const SIZES = { + '300x250': { width: 300, height: 250 }, + '728x90': { width: 728, height: 90 }, + }; + + const kit = document.querySelector('[data-partner-kit]'); + if (!kit) return; + const part = (name) => kit.querySelector(`[data-kit-${name}]`); + const body = part('body'); + const ended = part('ended'); + if (Date.now() >= ENDED) { + if (ended) ended.hidden = false; + return; + } + if (body) body.hidden = false; + + const picker = part('picker'); + const hint = part('hint'); + const materials = part('materials'); + const link = part('link'); + const general = part('general'); + const qr = part('qr'); + const qrPng = part('qr-png'); + const qrSvg = part('qr-svg'); + const snippet = part('snippet'); + const preview = part('preview'); + const description = kit.dataset.description ?? ''; + if (!(picker instanceof HTMLSelectElement)) return; + + const slugs = new Set(Array.from(picker.options, (option) => option.value).filter(Boolean)); + const linkFor = (slug) => + slug === GENERAL ? 'https://lvwwd.org/giveaway' : `https://lvwwd.org/giveaway?ref=${slug}`; + const chosenSize = () => kit.querySelector('[data-kit-size]:checked')?.value ?? '300x250'; + + function snippetFor(href, size) { + const { width, height } = SIZES[size]; + return [ + ``, + ` ${description}`, + '', + ].join('\n'); + } + + function showSnippet(href) { + const size = chosenSize(); + if (snippet) snippet.value = snippetFor(href, size); + if (!preview) return; + const { width, height } = SIZES[size]; + const anchor = document.createElement('a'); + anchor.href = href; + const image = document.createElement('img'); + image.src = `/partners/banners/${size}.png`; + image.width = width; + image.height = height; + image.alt = description; + image.style.maxWidth = '100%'; + image.style.height = 'auto'; + anchor.append(image); + preview.replaceChildren(anchor); + } + + function show(slug) { + const known = slugs.has(slug); + if (hint) hint.hidden = known; + if (materials) materials.hidden = !known; + if (!known) return; + const href = linkFor(slug); + if (link) link.textContent = href; + if (general) general.hidden = slug !== GENERAL; + if (qr) { + qr.src = `/partners/qr/${slug}.svg`; + qr.alt = `QR code for ${href.replace('https://', '')}`; + } + qrPng?.setAttribute('href', `/partners/qr/${slug}.png`); + qrSvg?.setAttribute('href', `/partners/qr/${slug}.svg`); + showSnippet(href); + } + + function fromAddress() { + const slug = /^#kit-([a-z0-9-]+)$/.exec(window.location.hash)?.[1] ?? ''; + picker.value = slugs.has(slug) ? slug : ''; + show(picker.value); + } + + picker.addEventListener('change', () => { + const slug = picker.value; + if (slugs.has(slug)) window.history.replaceState(null, '', `#kit-${slug}`); + show(slug); + }); + kit.querySelectorAll('[data-kit-size]').forEach((radio) => { + radio.addEventListener('change', () => show(picker.value)); + }); + window.addEventListener('hashchange', fromAddress); + fromAddress(); +})(); 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/public/scripts/share-screen.js b/apps/site/public/scripts/share-screen.js new file mode 100644 index 0000000..5f65cbc --- /dev/null +++ b/apps/site/public/scripts/share-screen.js @@ -0,0 +1,156 @@ +/** + * The share screen: a full-screen panel that makes a picture on the phone + * and offers it to the phone's share sheet, with its written description. + * The markup is src/components/ShareScreen.astro. + * + * window.lvwwdShareScreen.open(picture, opener) shows a picture definition: + * { heading, fileName, width, height, note, fonts, draw(canvas), describe() }. + * Closing it (Close, Escape, or the phone's back gesture) puts focus back + * on `opener`. The picture is made from what is on the phone: nothing is + * uploaded and nothing is stored. + */ +(() => { + const dialog = document.querySelector('[data-share-screen]'); + if (!(dialog instanceof HTMLDialogElement)) return; + + const part = (name) => dialog.querySelector(`[data-share-screen-${name}]`); + const heading = part('heading'); + const making = part('making'); + const image = part('image'); + const shareButton = part('share'); + const copyButton = part('copy'); + const saved = part('saved'); + const copied = part('copied'); + const description = part('description'); + const note = part('note'); + + let opener = null; + let current = null; + let run = 0; + + const say = (el, text) => { + if (el) el.textContent = text; + }; + + function waitForFonts(fonts) { + const loads = Promise.all((fonts ?? []).map((font) => document.fonts.load(font))); + const limit = new Promise((resolve) => window.setTimeout(resolve, 3000)); + return Promise.race([loads, limit]).catch(() => undefined); + } + + async function make(picture) { + await waitForFonts(picture.fonts); + const canvas = document.createElement('canvas'); + canvas.width = picture.width; + canvas.height = picture.height; + picture.draw(canvas); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) return null; + return { + file: new File([blob], picture.fileName, { type: 'image/png' }), + url: URL.createObjectURL(blob), + text: picture.describe(), + fileName: picture.fileName, + }; + } + + function save() { + if (!current) return; + const link = document.createElement('a'); + link.href = current.url; + link.download = current.fileName; + document.body.append(link); + link.click(); + link.remove(); + say(saved, `Image saved. Look for ${current.fileName} in your downloads.`); + } + + function canShareFile(file) { + try { + return Boolean(navigator.canShare?.({ files: [file] })); + } catch { + return false; + } + } + + async function share() { + if (!current) return; + if (!canShareFile(current.file)) { + save(); + return; + } + try { + await navigator.share({ files: [current.file] }); + } catch (error) { + if (error?.name !== 'AbortError') save(); + } + } + + async function copy() { + if (!current) return; + try { + await navigator.clipboard.writeText(current.text); + say(copied, 'Description copied.'); + } catch { + say(copied, "Couldn't copy. Press and hold the description above to select and copy it."); + } + } + + function close(fromHistory) { + if (!dialog.open) return; + run++; + dialog.close(); + if (current) URL.revokeObjectURL(current.url); + current = null; + if (!fromHistory && window.history.state?.lvwwdShareScreen) window.history.back(); + opener?.focus(); + } + + async function open(picture, from) { + opener = from ?? null; + const thisRun = ++run; + say(heading, picture.heading); + say(note, picture.note ?? ''); + if (note) note.hidden = !picture.note; + say(description, ''); + say(saved, ''); + say(copied, ''); + if (making) making.hidden = false; + if (image) image.hidden = true; + [shareButton, copyButton].forEach((button) => { + if (button) button.disabled = true; + }); + + if (!dialog.open) { + dialog.showModal(); + window.history.pushState({ lvwwdShareScreen: true }, ''); + } + heading?.focus(); + + const made = await make(picture); + if (thisRun !== run || !made) return; + current = made; + if (image instanceof HTMLImageElement) { + image.src = made.url; + image.alt = made.text; + image.hidden = false; + } + if (making) making.hidden = true; + say(description, made.text); + say(shareButton, canShareFile(made.file) ? 'Share image' : 'Save image'); + [shareButton, copyButton].forEach((button) => { + if (button) button.disabled = false; + }); + } + + part('close')?.addEventListener('click', () => close(false)); + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + close(false); + }); + window.addEventListener('popstate', () => close(true)); + shareButton?.addEventListener('click', share); + copyButton?.addEventListener('click', copy); + + window.lvwwdShareScreen = { open }; +})(); diff --git a/apps/site/scripts/build-banners.mjs b/apps/site/scripts/build-banners.mjs new file mode 100644 index 0000000..f978fe8 --- /dev/null +++ b/apps/site/scripts/build-banners.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Draws the four partner banners in public/partners/banners from the site's +// own fonts and colors, at their exact pixel sizes. Run it again after the +// banners in src/lib/banners.ts or the drawing below change: +// +// node scripts/build-banners.mjs +// +// Each banner shows "Week Without Driving Las Vegas", "October 1 to 8, +// 2026" and "Sign up to win at lvwwd.org", with the four ways of getting +// around as icons. Partners' websites show these files by their fixed +// addresses, so a new design keeps the same file names. LVBT's media +// committee can also replace any file with its own design of the same size. +// +// Chromium comes with the Playwright the tests already use, and sharp with +// Astro, so the script adds no dependency. Node reads the TypeScript list +// directly. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from '@playwright/test'; + +import { BANNER_COLORS as COLORS, BANNER_LINES as LINES, BANNERS } from '../src/lib/banners.ts'; + +const site = new URL('../', import.meta.url); +const require = createRequire(import.meta.url); +const sharp = createRequire(import.meta.resolve('astro'))('sharp'); +const icons = require('@iconify-json/mdi/icons.json'); + +const font = (file) => + `url(data:font/woff2;base64,${readFileSync(new URL(`public/fonts/${file}`, site)).toString('base64')}) format('woff2')`; +const icon = (name) => + ``; + +// Sizes in each banner's own pixels: [date, headline, button, icon, padding]. +const SCALE = { + '1080x1080': { date: 44, headline: 128, button: 52, icon: 84, pad: 96 }, + '1080x1920': { date: 60, headline: 172, button: 64, icon: 120, pad: 110 }, + '300x250': { date: 14, headline: 31, button: 15, icon: 22, pad: 18 }, + '728x90': { date: 15, headline: 25, button: 17, icon: 0, pad: 20 }, +}; + +function page(banner) { + const s = SCALE[`${banner.width}x${banner.height}`]; + const ways = ['bus', 'walk', 'bike', 'car'].map((name) => icon(name)).join(''); + const body = + banner.layout === 'strip' + ? `
+

${LINES.name}

${LINES.dates}

+

${LINES.signUp}

+
` + : `
+

${LINES.dates}

+

Week Without Driving
Las Vegas

+

${ways}

+

${LINES.signUp}

+
`; + return `${body}`; +} + +const out = fileURLToPath(new URL('public/partners/banners/', site)); +mkdirSync(out, { recursive: true }); +const browser = await chromium.launch(); +try { + for (const banner of BANNERS) { + const context = await browser.newContext({ + viewport: { width: banner.width, height: banner.height }, + deviceScaleFactor: 1, + }); + const tab = await context.newPage(); + await tab.setContent(page(banner)); + await tab.evaluate(() => document.fonts.ready); + const shot = await tab.screenshot({ type: 'png' }); + const png = await sharp(shot) + .png({ palette: true, quality: 100, compressionLevel: 9 }) + .toBuffer(); + writeFileSync(`${out}${banner.file}`, png); + console.log( + `${banner.file}: ${banner.width} × ${banner.height}, ${png.length.toLocaleString()} bytes`, + ); + await context.close(); + } +} finally { + await browser.close(); +} 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/components/KeepGoingCard.astro b/apps/site/src/components/KeepGoingCard.astro new file mode 100644 index 0000000..c4a6035 --- /dev/null +++ b/apps/site/src/components/KeepGoingCard.astro @@ -0,0 +1,191 @@ +--- +import { lvbt } from '../lib/site'; +import { NEWSLETTER_PAGE, ONE_TAP_NEWSLETTER } from '../lib/newsletter'; + +// The invitation to keep going with LVBT: a newsletter sign-up and the +// membership link. My week shows it as "Keep going after the week"; Home +// shows it as "Stay involved with LVBT" once the giveaway closes. +// +// Everyone sees the link to LVBT's newsletter page and "Become a member". +// When the one-tap sign-up is on (src/lib/newsletter.ts), /scripts/keep-going.js +// swaps the link for "Join the newsletter": with the signed-in participant's +// own address on My week, or an email field. + +interface Props { + /** The anchor that opens the page at this card. */ + id: string; + heading: string; + text: string; + /** Where the sign-up came from, as the newsletter route records it. */ + source: 'keep_going' | 'home'; +} + +const { id, heading, text, source } = Astro.props; +const headingId = `${id}-heading`; +--- + +
+

{heading}

+

{text}

+ + + + +

+ +

Ready to do more?

+ Become a member +
+ + diff --git a/apps/site/src/components/PartnerKit.astro b/apps/site/src/components/PartnerKit.astro new file mode 100644 index 0000000..1bab5ba --- /dev/null +++ b/apps/site/src/components/PartnerKit.astro @@ -0,0 +1,349 @@ +--- +import { Icon } from 'astro-icon/components'; +import { BANNER_DESCRIPTION as DESCRIPTION, BANNERS } from '../lib/banners'; +import { contactEmail, GENERAL, partners } from '../lib/partners'; + +// The partner kit: pick an organization, then get its link, QR code, the +// four banners and an embed snippet for its website. It needs JavaScript +// (/scripts/partner-kit.js fills in the chosen organization's link), so +// without it the kit shows only its heading and a way to ask by email. +// The copy buttons are run by /scripts/partners.js. + +const bannerCards = BANNERS.map((banner) => ({ + ...banner, + size: `${banner.width}x${banner.height}`, + src: `/partners/banners/${banner.file}`, +})); +--- + +
+

Partner kit

+ + + + + + +
+ + diff --git a/apps/site/src/components/ShareScreen.astro b/apps/site/src/components/ShareScreen.astro new file mode 100644 index 0000000..fe3123b --- /dev/null +++ b/apps/site/src/components/ShareScreen.astro @@ -0,0 +1,146 @@ +--- +// The share screen: a full-screen panel that shows a picture made on the +// phone, a button to share or save it, and its written description, ready +// to paste as alt text. /scripts/share-screen.js fills it in and opens it; +// the page's own script decides which picture to show. +--- + + + + + + + + + + + + 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/banners.ts b/apps/site/src/lib/banners.ts new file mode 100644 index 0000000..9cd96f7 --- /dev/null +++ b/apps/site/src/lib/banners.ts @@ -0,0 +1,80 @@ +// The partner banners: their sizes, colors and words. The Partners page's +// kit offers them, and scripts/build-banners.mjs draws the PNG files in +// public/partners/banners from this list. + +/** The site's own palette (src/styles/global.css). */ +export const BANNER_COLORS = { + background: '#0e5f66', // --teal-deep, the slab + text: '#fbf4e6', // --sand + quiet: '#cfe7e4', // --slab-mist + button: '#ff6b4a', // --coral-bright + onButton: '#1a1210', // --on-coral +} as const; + +/** Every text on a banner and the color behind it; each must reach 4.5 to 1. */ +export const BANNER_TEXT_PAIRS: ReadonlyArray = [ + ['headline', BANNER_COLORS.text, BANNER_COLORS.background], + ['dates', BANNER_COLORS.quiet, BANNER_COLORS.background], + ['sign-up line', BANNER_COLORS.onButton, BANNER_COLORS.button], +]; + +/** The three lines every banner shows. */ +export const BANNER_LINES = { + name: 'Week Without Driving Las Vegas', + dates: 'October 1 to 8, 2026', + signUp: 'Sign up to win at lvwwd.org', +} as const; + +/** The image description of every banner, and the alt text in the embed snippet. */ +export const BANNER_DESCRIPTION = + 'Week Without Driving Las Vegas, October 1 to 8, 2026. Sign up to win at lvwwd.org.'; + +export interface Banner { + file: string; + width: number; + height: number; + /** The most the file may weigh, so it loads quickly on partners' pages. */ + maxBytes: number; + name: string; + purpose: string; + layout: 'stack' | 'strip'; +} + +export const BANNERS: readonly Banner[] = [ + { + file: '1080x1080.png', + width: 1080, + height: 1080, + maxBytes: 1_000_000, + name: 'Square post', + purpose: 'For Instagram and Facebook posts.', + layout: 'stack', + }, + { + file: '1080x1920.png', + width: 1080, + height: 1920, + maxBytes: 1_500_000, + name: 'Story', + purpose: 'For Instagram and Facebook stories and WhatsApp status.', + layout: 'stack', + }, + { + file: '300x250.png', + width: 300, + height: 250, + maxBytes: 150_000, + name: 'Website box', + purpose: 'For a website sidebar or newsletter.', + layout: 'stack', + }, + { + file: '728x90.png', + width: 728, + height: 90, + maxBytes: 100_000, + name: 'Website strip', + purpose: 'For the top of a web page on a computer.', + layout: 'strip', + }, +]; 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/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/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/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/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/bingo.astro b/apps/site/src/pages/bingo.astro index 2e5aebf..3ea5e44 100644 --- a/apps/site/src/pages/bingo.astro +++ b/apps/site/src/pages/bingo.astro @@ -1,21 +1,20 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import WinBand from '../components/WinBand.astro'; +import BingoPrintSheet from '../components/BingoPrintSheet.astro'; +import ShareScreen from '../components/ShareScreen.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