1// BugReportModal.js: the branded, in-archive report form (#509). 2// 3// This is the themed layer that sits between the header "Report a bug" button 4// and GitHub. Instead of dropping the reader on github.com's new-issue form 5// (which needs an account most readers do not have), it collects the report in 6// the archive's own look and POSTs it to the Apps Script web app, which files 7// the GitHub issue on the reader's behalf. The reader never leaves the site. 8// 9// Two intents share the form: report a problem, or suggest a record to add. 10// If no endpoint is configured yet (pre-deploy), submit falls back to the 11// existing GitHub deep link so the button always does something useful. 12// 13// Styling follows the archive design system (Special Elite display, Roboto Mono 14// body, paper background, stone palette, sky focus rings) and deliberately 15// avoids the AI-frontend tells: no eyebrow labels, no accent-sliver cards, no 16// decorative italics, one primary action. 17 18import { useEffect, useState, useRef, useCallback } from 'react'; 19import { html } from '../html.js?v=3.8.36'; 20import { X, Bug, Lightbulb, Send, CheckCircle2, AlertCircle, ExternalLink } from 'lucide-react'; 21import { ARCHIVE_VERSION, openReportFallback } from '../utils/bugReport.js?v=3.8.36'; 22import { createSubmitGate } from '../utils/submitGate.js?v=3.8.36'; 23import { buildReportPayload, validateReport, submitReport, newReportKey } from '../utils/reportSubmit.js?v=3.8.36'; 24import { acquireBodyScrollLock } from '../services/bodyScrollLock.js?v=3.8.36'; 25 26const EMPTY_FIELDS = { 27 whatHappened: '', 28 expected: '', 29 steps: '', 30 url: '', 31 title: '', 32 why: '', 33 email: '', 34}; 35 36const NO_INITIAL_FIELDS = Object.freeze({}); 37 38// Read the debug context the reporter should not have to type. The modal 39// snapshots this once per opening so later history navigation cannot erase the 40// record or route the reader was reporting from. 41const captureContext = () => ({ 42 page: typeof window !== 'undefined' ? window.location.href : '', 43 version: ARCHIVE_VERSION, 44 browser: typeof navigator !== 'undefined' ? navigator.userAgent : '', 45}); 46 47const BugReportModal = ({ isOpen, onClose, endpoint = '', initialIntent = 'problem', initialFields = NO_INITIAL_FIELDS }) => { 48 const [intent, setIntent] = useState('problem'); 49 const [fields, setFields] = useState({ ...EMPTY_FIELDS }); 50 const [honeypot, setHoneypot] = useState(''); 51 const [phase, setPhase] = useState('form'); // form | submitting | success | error | fallback 52 const [issueUrl, setIssueUrl] = useState(''); 53 const [formError, setFormError] = useState(''); 54 // One idempotency key per opened report, reused across retries so the server 55 // dedupes them; regenerated on each open (see the reset effect below). 56 const [reportKey, setReportKey] = useState(''); 57 58 const modalRef = useRef(null); 59 const closeButtonRef = useRef(null); 60 const firstFieldRef = useRef(null); 61 const returnFocusRef = useRef(null); 62 const wasOpenRef = useRef(false); 63 const reportContextRef = useRef(captureContext()); 64 // Owns the submit lifecycle: blocks a synchronous double-submit, allows a fresh 65 // submit after reopen, and drops a stale in-flight result. Pure + unit-tested 66 // in submitGate.js so these races cannot silently regress. useRef with an 67 // initializer function so the gate is created once per mount, not per render. 68 const gateRef = useRef(null); 69 if (gateRef.current === null) gateRef.current = createSubmitGate(); 70 71 // Reset to a clean form each time the modal opens so a prior submission's 72 // state never leaks into a new report. 73 useEffect(() => { 74 if (isOpen) { 75 gateRef.current.reset(); 76 reportContextRef.current = captureContext(); 77 setIntent(initialIntent === 'record' ? 'record' : 'problem'); 78 setFields({ ...EMPTY_FIELDS, ...initialFields }); 79 setHoneypot(''); 80 setPhase('form'); 81 setIssueUrl(''); 82 setFormError(''); 83 // Fresh idempotency key per report: retries of this report reuse it (so 84 // the server dedupes), but a new report gets a new key. 85 setReportKey(newReportKey()); 86 } 87 }, [isOpen, initialIntent, initialFields]); 88 89 // Focus the first field on open (or the close button on the result screens). 90 useEffect(() => { 91 if (!isOpen) return; 92 const t = setTimeout(() => { 93 if (phase === 'form' && firstFieldRef.current) firstFieldRef.current.focus(); 94 else if (closeButtonRef.current) closeButtonRef.current.focus(); 95 }, 80); 96 return () => clearTimeout(t); 97 }, [isOpen, phase, intent]); 98 99 // Remember the exact trigger so every close path returns keyboard focus, 100 // whether the report opened from the archive header, Start here, or a 101 // desktop shortcut/menu item. 102 useEffect(() => { 103 let frame = null; 104 if (isOpen && !wasOpenRef.current) { 105 returnFocusRef.current = document.activeElement; 106 } else if (!isOpen && wasOpenRef.current) { 107 const target = returnFocusRef.current; 108 frame = requestAnimationFrame(() => {
109 if (target?.isConnected && typeof target.focus === 'function') { 110 target.focus({ preventScroll: true }); 111 } 112 }); 113 returnFocusRef.current = null; 114 } 115 wasOpenRef.current = isOpen; 116 return () => { if (frame !== null) cancelAnimationFrame(frame); }; 117 }, [isOpen]); 118 119 // A submit in flight is the single source of truth: block every dismissal path 120 // (Escape, backdrop click, the close button) until it settles. Otherwise a 121 // reader could close mid-request, reopen to a fresh form, and file the same 122 // report a second time while the first POST is still completing server-side. 123 // submitReport's own timeout guarantees this lock releases (the phase leaves 124 // 'submitting') within 15s, so the modal can never wedge shut. 125 const requestClose = useCallback(() => { 126 if (phase === 'submitting') return; 127 onClose(); 128 }, [phase, onClose]); 129 130 // ESC closes. Tab and Shift+Tab stay inside the modal instead of moving 131 // focus into the visually obscured archive or desktop behind it. 132 useEffect(() => { 133 const onKey = (e) => { 134 if (!isOpen) return; 135 if (e.key === 'Escape') { 136 // This modal can sit above an open record reader. Consume Escape here 137 // so the same keystroke cannot bubble into and close the underlying 138 // record dialog after this report unmounts. 139 e.preventDefault(); 140 e.stopPropagation(); 141 requestClose(); 142 return; 143 } 144 if (e.key !== 'Tab') return; 145 146 const focusable = [...(modalRef.current?.querySelectorAll( 147 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' 148 ) || [])].filter((element) => element.getClientRects().length > 0); 149 if (focusable.length === 0) { 150 e.preventDefault(); 151 closeButtonRef.current?.focus(); 152 return; 153 } 154 155 const first = focusable[0]; 156 const last = focusable[focusable.length - 1]; 157 const focusOutside = !modalRef.current?.contains(document.activeElement); 158 if (e.shiftKey && (document.activeElement === first || focusOutside)) { 159 e.preventDefault(); 160 last.focus(); 161 } else if (!e.shiftKey && (document.activeElement === last || focusOutside)) { 162 e.preventDefault(); 163 first.focus(); 164 } 165 }; 166 document.addEventListener('keydown', onKey); 167 return () => document.removeEventListener('keydown', onKey); 168 }, [isOpen, requestClose]); 169 170 useEffect(() => { 171 if (!isOpen) return undefined; 172 return acquireBodyScrollLock(); 173 }, [isOpen]); 174 175 const setField = useCallback((name, value) => { 176 setFields((prev) => ({ ...prev, [name]: value })); 177 if (formError) setFormError(''); 178 }, [formError]); 179 180 const handleBackdrop = (e) => { if (e.target === e.currentTarget) requestClose(); }; 181 182 // Open the intent-aware GitHub fallback, carrying what the reader entered so 183 // a record suggestion does not land on the bug form and lose its url/title/why. 184 const openFallback = useCallback( 185 () => openReportFallback({ intent, fields, context: reportContextRef.current }), 186 [intent, fields], 187 ); 188 189 const handleSubmit = useCallback(async (e) => { 190 if (e && e.preventDefault) e.preventDefault(); 191 192 const payload = buildReportPayload({ intent, fields, context: reportContextRef.current, honeypot, idempotencyKey: reportKey }); 193 194 const check = validateReport(payload); 195 if (!check.valid) { setFormError(check.error); return; } 196 197 // Claim the submit slot. begin() returns 0 if one is already in flight (a 198 // double-click before the disabled button renders), blocking a duplicate 199 // file. The gate also allows a fresh submit after reopen; see submitGate.js. 200 const seq = gateRef.current.begin(); 201 if (!seq) return; 202 setPhase('submitting'); 203 try { 204 const result = await submitReport({ endpoint, payload }); 205 206 // The modal was closed/reopened while this was in flight: drop the stale 207 // result so it cannot overwrite the current session's state. 208 if (!gateRef.current.isCurrent(seq)) return; 209 210 if (result.fallback) {
211 // Do not window.open() here: the await above may have outlived the click 212 // gesture (a honeypot false-positive routes here after a round-trip), so 213 // a popup would be blocked and the report lost. Show a screen whose button 214 // opens GitHub from a fresh user gesture. Fires pre-deploy (no endpoint 215 // set) and on a honeypot false-positive. 216 setPhase('fallback'); 217 return; 218 } 219 if (result.ok) { 220 setIssueUrl(result.issueUrl || ''); 221 setPhase('success'); 222 return; 223 } 224 setFormError(result.error || 'Your report could not be sent.'); 225 setPhase('error'); 226 } finally { 227 gateRef.current.end(seq); 228 } 229 }, [intent, fields, honeypot, endpoint, reportKey, openFallback, onClose]); 230 231 if (!isOpen) return null; 232 233 const isProblem = intent === 'problem'; 234 const submitting = phase === 'submitting'; 235 236 const intentTab = (value, Icon, label) => html` 237 <button 238 type="button" 239 onClick=${() => { setIntent(value); setFormError(''); }} 240 disabled=${submitting} 241 aria-pressed=${intent === value} 242 className=${`archive-action flex-1 ${ 243 intent === value 244 ? 'archive-action--primary' 245 : 'archive-action--secondary' 246 }`} 247 > 248 <${Icon} className="w-4 h-4" aria-hidden="true" /> 249 ${label} 250 </button> 251 `; 252 253 const labelledTextarea = (name, label, placeholder, rows, refEl) => html` 254 <label className="block"> 255 <span className="block text-xs font-bold text-stone-700 mb-1">${label}</span> 256 <textarea 257 ref=${refEl || null} 258 value=${fields[name]} 259 onInput=${(e) => setField(name, e.target.value)} 260 disabled=${submitting} 261 rows=${rows} 262 placeholder=${placeholder} 263 className="archive-control" 264 ></textarea> 265 </label> 266 `; 267 268 const labelledInput = (name, label, placeholder, type = 'text', refEl) => html` 269 <label className="block"> 270 <span className="block text-xs font-bold text-stone-700 mb-1">${label}</span> 271 <input 272 ref=${refEl || null} 273 type=${type} 274 value=${fields[name]} 275 onInput=${(e) => setField(name, e.target.value)} 276 disabled=${submitting} 277 placeholder=${placeholder} 278 className="archive-control" 279 /> 280 </label> 281 `; 282 283 const body = () => { 284 if (phase === 'success') { 285 return html` 286 <div className="px-6 py-10 text-center"> 287 <${CheckCircle2} className="w-10 h-10 text-green-600 mx-auto mb-4" aria-hidden="true" /> 288 <h3 className="font-display text-xl text-stone-900 mb-2">Thank you</h3> 289 <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6"> 290 Your ${isProblem ? 'report' : 'suggestion'} reached the archive. We read every one. 291 </p> 292 ${issueUrl && html` 293 <a 294 href=${issueUrl} 295 target="_blank" 296 rel="noopener noreferrer" 297 className="inline-flex items-center gap-1.5 text-sm font-bold text-stone-700 hover:text-stone-900 underline" 298 > 299 View it on GitHub <${ExternalLink} className="w-3.5 h-3.5" aria-hidden="true" /> 300 </a> 301 `} 302 <div className="mt-8"> 303 <button 304 type="button" 305 onClick=${onClose} 306 className="archive-action archive-action--primary" 307 > 308 Done 309 </button> 310 </div> 311 </div> 312 `; 313 } 314 315 if (phase === 'fallback') { 316 // Reached when the archive backend could not file the report (pre-deploy, 317 // or a honeypot false-positive). The reader's data is preserved in the 318 // prefilled GitHub link; the button opens it on a real click so the popup 319 // is not blocked. 320 return html` 321 <div className="px-6 py-10 text-center"> 322 <${ExternalLink} className="w-10 h-10 text-stone-700 mx-auto mb-4" aria-hidden="true" /> 323 <h3 className="font-display text-xl text-stone-900 mb-2">Finish on GitHub</h3> 324 <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6">
325 We could not send this from here, so we filled in a GitHub issue with 326 what you entered. Open it to post your ${isProblem ? 'report' : 'suggestion'}. 327 </p> 328 <div className="flex items-center justify-center gap-3"> 329 <button 330 type="button" 331 onClick=${() => { openFallback(); onClose(); }} 332 className="archive-action archive-action--primary" 333 > 334 Open the issue form <${ExternalLink} className="w-4 h-4" aria-hidden="true" /> 335 </button> 336 <button 337 type="button" 338 onClick=${onClose} 339 className="archive-action archive-action--quiet" 340 > 341 Cancel 342 </button> 343 </div> 344 </div> 345 `; 346 } 347 348 if (phase === 'error') { 349 // A failed send is ambiguous: the request may have timed out or lost its 350 // response yet still filed the issue server-side (or a concurrent retry is 351 // filing it right now). "Try again" is the only safe recovery here because it 352 // reuses the idempotency key, so the server returns the existing issue rather 353 // than filing a duplicate. We deliberately do NOT offer a direct GitHub deep 354 // link on this screen: that path bypasses the key and would open a second 355 // public issue for the same report. Try again returns to the form, which 356 // still carries the pre-submit "Prefer GitHub?" fallback for a genuine 357 // outage â where nothing was filed, so GitHub is safe. 358 return html` 359 <div className="px-6 py-10 text-center"> 360 <${AlertCircle} className="w-10 h-10 text-red-600 mx-auto mb-4" aria-hidden="true" /> 361 <h3 className="font-display text-xl text-stone-900 mb-2">That did not go through</h3> 362 <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6">${formError}</p> 363 <div className="flex items-center justify-center gap-3"> 364 <button 365 type="button" 366 onClick=${() => setPhase('form')} 367 className="archive-action archive-action--primary" 368 > 369 Try again 370 </button> 371 </div> 372 </div> 373 `; 374 } 375 376 // phase === 'form' | 'submitting' 377 return html` 378 <form onSubmit=${handleSubmit} className="px-6 py-6 space-y-4"> 379 <div className="flex gap-2"> 380 ${intentTab('problem', Bug, 'Report a problem')} 381 ${intentTab('record', Lightbulb, 'Suggest a record')} 382 </div> 383 384 ${isProblem ? html` 385 ${labelledTextarea('whatHappened', 'What happened?', 'I opened a record and the page went blank.', 3, firstFieldRef)} 386 ${labelledTextarea('expected', 'What did you expect? (optional)', 'The record details should have opened.', 2)} 387 ${labelledTextarea('steps', 'Steps to reproduce (optional)', '1. Go to ...\n2. Click ...\n3. See ...', 3)} 388 ` : html` 389 ${labelledInput('url', 'Link to the work', 'https://pressthink.org/...', 'url', firstFieldRef)} 390 ${labelledInput('title', 'Suggested title (optional)', 'e.g. He Said, She Said journalism')} 391 ${labelledTextarea('why', 'Why it belongs in the archive (optional)', 'A short note on what this is.', 3)} 392 `} 393 394 ${labelledInput('email', 'Your email (optional, shown publicly)', 'So we can follow up if we need to.', 'email')} 395 396 <!-- Honeypot: off-screen, not announced, not tabbable. Real people never 397 fill this; naive bots do, and the server drops those silently. --> 398 <div aria-hidden="true" className="absolute left-[-9999px] top-auto w-px h-px overflow-hidden"> 399 <label> 400 Website 401 <input 402 type="text" 403 tabindex="-1" 404 autocomplete="off" 405 value=${honeypot} 406 onInput=${(e) => setHoneypot(e.target.value)} 407 /> 408 </label> 409 </div> 410 411 <p className="text-xs text-stone-500 leading-relaxed"> 412 ${isProblem 413 ? 'We automatically attach the page you are on, the archive version, and
413your browser so we can reproduce the problem. Your report is posted as a public GitHub issue, so please leave out anything private. No account needed.' 414 : 'We automatically attach the page you are on and the archive version. Your suggestion is posted as a public GitHub issue, so please leave out anything private. No account needed.'} 415 </p> 416 417 ${formError && html` 418 <p role="alert" className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-sm px-3 py-2"> 419 ${formError} 420 </p> 421 `} 422 423 <div className="flex items-center justify-between pt-1"> 424 <button 425 type="button" 426 onClick=${() => { openFallback(); onClose(); }} 427 disabled=${submitting} 428 className="archive-action archive-action--quiet" 429 > 430 Prefer GitHub? Open the issue form 431 </button> 432 <button 433 type="submit" 434 disabled=${submitting} 435 className="archive-action archive-action--primary" 436 > 437 <${Send} className="w-4 h-4" aria-hidden="true" /> 438 ${submitting ? 'Sending...' : 'Send report'} 439 </button> 440 </div> 441 </form> 442 `; 443 }; 444 445 return html` 446 <div 447 className="archive-report-dialog archive-dialog-backdrop z-[100]" 448 onClick=${handleBackdrop} 449 role="dialog" 450 aria-modal="true" 451 aria-labelledby="bug-report-title" 452 > 453 <div ref=${modalRef} className="archive-dialog archive-report-dialog__panel flex flex-col overflow-hidden"> 454 <div className="archive-report-dialog__header flex-shrink-0 px-6 py-4 flex items-center justify-between gap-4"> 455 <div className="flex items-center gap-2"> 456 <${Bug} className="w-5 h-5 text-stone-700 flex-shrink-0" aria-hidden="true" /> 457 <h2 id="bug-report-title" className="font-display text-xl text-stone-800"> 458 Report a problem or suggest a record 459 </h2> 460 </div> 461 <button 462 ref=${closeButtonRef} 463 onClick=${requestClose} 464 disabled=${submitting} 465 className="archive-action archive-action--quiet archive-report-dialog-close flex-shrink-0" 466 aria-label="Close report form" 467 > 468 <${X} className="w-5 h-5" aria-hidden="true" /> 469 </button> 470 </div> 471 <!-- Inline minHeight:0 (not a Tailwind min-h-0 class): the pre-built, 472 zero-build stylesheet has no min-h-0 rule, and a flex child defaults 473 to min-height:auto, which refuses to shrink below its content and 474 defeats overflow-y-auto. This is the body's own scroll region. --> 475 <div className="flex-1 overflow-y-auto" style=${{ minHeight: 0 }}> 476 ${body()} 477 </div> 478 </div> 479 </div> 480 `; 481}; 482 483export default BugReportModal;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.