1(() => { 2 'use strict'; 3 4 const staticFormsEndpoint = 'https://api.staticforms.dev/submit'; 5 6 // Build a consistent, accessible mobile menu from the site's primary links. 7 // Some pages already contain a hamburger button while others do not, so this 8 // routine upgrades the existing control where present and creates it where needed. 9 function initialiseMobileNavigation() { 10 const nav = document.querySelector('nav'); 11 if (!nav || nav.querySelector('#dontfail-mobile-menu')) return; 12 13 const primaryHrefs = [ 14 'index.html', 15 'episodes.html', 16 'hall-of-fame.html', 17 'tools-hub.html' 18 ]; 19 20 const labels = { 21 'index.html': 'Home', 22 'episodes.html': 'Episodes', 23 'hall-of-fame.html': 'Hall of Fame', 24 'tools-hub.html': 'Prevention Tools' 25 }; 26 27 const navRow = Array.from(nav.querySelectorAll('div')).find((element) => 28 element.classList.contains('flex') && 29 element.classList.contains('justify-between') && 30 element.classList.contains('items-center') 31 ); 32 if (!navRow) return; 33 34 let toggleButton = Array.from(nav.querySelectorAll('button')).find((button) => { 35 const srOnly = button.querySelector('.sr-only'); 36 return srOnly && /open menu/i.test(srOnly.textContent || ''); 37 }); 38 39 if (!toggleButton) { 40 const toggleWrapper = document.createElement('div'); 41 toggleWrapper.className = '-mr-2 flex items-center sm:hidden'; 42 toggleWrapper.innerHTML = ` 43 <button type="button" class="text-gray-200 hover:text-white p-2" aria-label="Open menu"> 44 <span class="sr-only">Open menu</span> 45 <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> 46 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> 47 </svg> 48 </button>`; 49 navRow.appendChild(toggleWrapper); 50 toggleButton = toggleWrapper.querySelector('button'); 51 } 52 53 toggleButton.type = 'button'; 54 toggleButton.setAttribute('aria-controls', 'dontfail-mobile-menu'); 55 toggleButton.setAttribute('aria-expanded', 'false'); 56 if (!toggleButton.getAttribute('aria-label')) toggleButton.setAttribute('aria-label', 'Open menu'); 57 const toggleIcon = toggleButton.querySelector('svg'); 58 if (toggleIcon) toggleIcon.setAttribute('aria-hidden', 'true'); 59 60 const mobileMenu = document.createElement('div'); 61 mobileMenu.id = 'dontfail-mobile-menu'; 62 mobileMenu.className = 'hidden sm:hidden border-t border-white/10 bg-navy'; 63 64 const menuInner = document.createElement('div'); 65 menuInner.className = 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 space-y-1'; 66 67 const currentPage = (window.location.pathname.split('/').pop() || 'index.html').toLowerCase(); 68 primaryHrefs.forEach((href) => { 69 const link = document.createElement('a'); 70 link.href = href; 71 link.textContent = labels[href]; 72 link.className = 'block rounded px-3 py-3 text-base font-medium text-white hover:bg-white/10 hover:text-gold transition'; 73 74 if (currentPage === href.toLowerCase()) { 75 link.classList.add('bg-white/10', 'text-gold'); 76 link.setAttribute('aria-current', 'page'); 77 } 78 79 link.addEventListener('click', () => closeMenu()); 80 menuInner.appendChild(link); 81 }); 82 83 mobileMenu.appendChild(menuInner); 84 nav.appendChild(mobileMenu); 85 86 function setMenuState(open) { 87 mobileMenu.classList.toggle('hidden', !open); 88 toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false'); 89 toggleButton.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); 90 const srOnly = toggleButton.querySelector('.sr-only'); 91 if (srOnly) srOnly.textContent = open ? 'Close menu' : 'Open menu'; 92 } 93 94 function closeMenu() { 95 setMenuState(false); 96 } 97 98 toggleButton.addEventListener('click', () => { 99 const isOpen = toggleButton.getAttribute('aria-expanded') === 'true'; 100 setMenuState(!isOpen); 101 }); 102 103 document.addEventListener('keydown', (event) => { 104 if (event.key === 'Escape') closeMenu(); 105 }); 106 107 document.addEventListener('click', (event) => { 108 if (toggleButton.getAttribute('aria-expanded') !== 'true') return; 109 if (!nav.contains(event.target)) closeMenu(); 110 }); 111 112 window.addEventListener('resize', () => { 113 if (window.innerWidth >= 640) closeMenu(); 114 }); 115 } 116 117 // Hall of Fame keeps a page-specific provenance block above the standard site footer. 118 // Its legacy footer is upgraded here before StaticForms handlers are attached.
119 function ensureHallOfFameFooter() { 120 if (!document.getElementById('fail-submission-form')) return; 121 122 const existingFooter = document.querySelector('footer'); 123 if (!existingFooter) return; 124 if (existingFooter.querySelector('form[aria-label="dontfail.ai newsletter signup"]')) return; 125 126 existingFooter.outerHTML = ` 127 <footer class="bg-dark text-white pt-12 pb-8"> 128 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> 129 <div class="bg-navy rounded-xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between shadow-2xl mb-12 border border-gray-700"> 130 <div class="mb-6 md:mb-0 md:pr-8"> 131 <h2 class="text-2xl font-bold text-white mb-2">Never miss a disaster</h2> 132 <p class="text-gray-300">Subscribe for episode notifications and Monday Morning Actions.</p> 133 </div> 134 <div class="w-full md:w-auto"> 135 <form 136 class="flex flex-col sm:flex-row gap-3" 137 data-staticforms="true" 138 data-success-message="Thanks - you're signed up for dontfail.ai updates." 139 data-error-message="Something went wrong. Please try again." 140 aria-label="dontfail.ai newsletter signup" 141 > 142 <input type="hidden" name="accessKey" value="" /> 143 <input type="hidden" name="subject" value="dontfail.ai newsletter signup" /> 144 <input type="hidden" name="formType" value="Newsletter signup" /> 145 <input type="text" name="honeypot" class="hidden" tabindex="-1" autocomplete="off" aria-hidden="true" /> 146 <div class="flex flex-col gap-2"> 147 <label for="newsletter-email-hall" class="sr-only">Work email address</label> 148 <input 149 id="newsletter-email-hall" 150 name="email" 151 type="email" 152 placeholder="[email protected]" 153 autocomplete="email" 154 class="px-4 py-3 rounded text-dark focus:outline-none focus:ring-2 focus:ring-gold w-full sm:w-64" 155 required 156 /> 157 <div class="form-status text-sm" role="status" aria-live="polite" hidden></div> 158 </div> 159 <button 160 type="submit" 161 class="bg-gold hover:bg-[#bfa030] text-navy font-bold px-6 py-3 rounded transition whitespace-nowrap self-start sm:self-auto" 162 > 163 Subscribe 164 </button> 165 </form> 166 </div> 167 </div> 168 169 <div class="border-t border-gray-700 pt-8 flex flex-col md:flex-row justify-between items-center text-sm text-gray-400"> 170 <p> 171 © 2025-2026 172 <a href="https://www.aibok.org/" class="hover:text-white hover:underline">AIBoK</a> 173 & 174 <a href="https://www.cirrolytix.com/" class="hover:text-white hover:underline">CirroLytix</a>. 175 All rights reserved. 176 </p> 177 <div class="flex space-x-6 mt-4 md:mt-0"> 178 <a href="privacy-policy.html" class="hover:text-white">Privacy Policy</a> 179 <a href="terms-of-use.html" class="hover:text-white">Terms of Use</a> 180 </div> 181 </div> 182 </div> 183 </footer>`; 184 } 185 186 function ensureHallOfFameModalAccessibility() { 187 function initialise() { 188 const modal = document.getElementById('incidentModal'); 189 const modalContent = document.getElementById('modalContent'); 190 const closeButton = document.getElementById('closeModal'); 191 const incidentsGrid = document.getElementById('incidentsGrid'); 192 if (!modal || !modalContent || !closeButton || !incidentsGrid) return; 193 194 modalContent.setAttribute('role', 'dialog'); 195 modalContent.setAttribute('aria-modal', 'true'); 196 modalContent.setAttribute('aria-labelledby', 'm-title'); 197 modalContent.setAttribute('aria-describedby', 'm-desc'); 198 modalContent.setAttribute('tabindex', '-1'); 199 modal.setAttribute('aria-hidden', 'true'); 200 201 closeButton.type = 'button'; 202 closeButton.setAttribute('aria-label', 'Close incident details'); 203 const closeIcon = closeButton.querySelector('svg'); 204 if (closeIcon) closeIcon.setAttribute('aria-hidden', 'true'); 205 206 let lastFocusedElement = null; 207 let modalWasOpen = false;
208 209 function isModalOpen() { 210 return document.body.classList.contains('modal-active') && !modal.classList.contains('hidden'); 211 } 212 213 function getFocusableElements() { 214 const selector = [ 215 'a[href]', 216 'button:not([disabled])', 217 'input:not([disabled])', 218 'select:not([disabled])', 219 'textarea:not([disabled])', 220 '[tabindex]:not([tabindex="-1"])' 221 ].join(','); 222 223 return Array.from(modalContent.querySelectorAll(selector)).filter((element) => 224 element.getClientRects().length > 0 && element.getAttribute('aria-hidden') !== 'true' 225 ); 226 } 227 228 function upgradeIncidentCards() { 229 incidentsGrid.querySelectorAll('.incident-card').forEach((card) => { 230 if (card.dataset.a11yReady === 'true') return; 231 card.dataset.a11yReady = 'true'; 232 card.setAttribute('role', 'button'); 233 card.setAttribute('tabindex', '0'); 234 card.setAttribute('aria-haspopup', 'dialog'); 235 236 const title = card.querySelector('h3'); 237 if (title && title.textContent.trim()) { 238 card.setAttribute('aria-label', `View incident details: ${title.textContent.trim()}`); 239 } 240 241 card.addEventListener('click', () => { 242 lastFocusedElement = card; 243 }); 244 245 card.addEventListener('keydown', (event) => { 246 if (event.key !== 'Enter' && event.key !== ' ') return; 247 event.preventDefault(); 248 lastFocusedElement = card; 249 card.click(); 250 }); 251 }); 252 } 253 254 function syncModalState() { 255 const open = isModalOpen(); 256 modal.setAttribute('aria-hidden', open ? 'false' : 'true'); 257 258 if (open && !modalWasOpen) { 259 if (!lastFocusedElement && document.activeElement && !modalContent.contains(document.activeElement)) { 260 lastFocusedElement = document.activeElement; 261 } 262 requestAnimationFrame(() => closeButton.focus()); 263 } 264 265 if (!open && modalWasOpen) { 266 const elementToRestore = lastFocusedElement; 267 lastFocusedElement = null; 268 if (elementToRestore && elementToRestore.isConnected && typeof elementToRestore.focus === 'function') { 269 requestAnimationFrame(() => elementToRestore.focus()); 270 } 271 } 272 273 modalWasOpen = open; 274 } 275 276 incidentsGrid.addEventListener('click', (event) => { 277 const card = event.target.closest('.incident-card'); 278 if (card) lastFocusedElement = card; 279 }, true); 280 281 const gridObserver = new MutationObserver(upgradeIncidentCards); 282 gridObserver.observe(incidentsGrid, { childList: true }); 283 upgradeIncidentCards(); 284 285 const modalObserver = new MutationObserver(syncModalState); 286 modalObserver.observe(modal, { attributes: true, attributeFilter: ['class'] }); 287 syncModalState(); 288 289 document.addEventListener('keydown', (event) => { 290 if (event.key !== 'Tab' || !isModalOpen()) return; 291 292 const focusableElements = getFocusableElements(); 293 if (focusableElements.length === 0) { 294 event.preventDefault(); 295 modalContent.focus(); 296 return; 297 } 298 299 const firstElement = focusableElements[0]; 300 const lastElement = focusableElements[focusableElements.length - 1]; 301 302 if (event.shiftKey && document.activeElement === firstElement) { 303 event.preventDefault(); 304 lastElement.focus(); 305 } else if (!event.shiftKey && document.activeElement === lastElement) { 306 event.preventDefault(); 307 firstElement.focus(); 308 } else if (!modalContent.contains(document.activeElement)) { 309 event.preventDefault(); 310 firstElement.focus(); 311 } 312 }); 313 } 314 315 if (document.readyState === 'loading') { 316 document.addEventListener('DOMContentLoaded', initialise, { once: true }); 317 } else { 318 initialise(); 319 } 320 } 321 322 function ensureEpisodesInteractionIntegrity() { 323 function initialise() { 324 const filter = document.getElementById('filter'); 325 const articles = Array.from(document.querySelectorAll('main article')); 326 if (!filter || articles.length === 0) return; 327 328 function applyFilter() { 329 const selected = filter.value.trim().toLowerCase();
330 articles.forEach((article) => { 331 if (selected === 'all tags') { 332 article.hidden = false; 333 return; 334 } 335 336 const hasSelectedTag = Array.from(article.querySelectorAll('span')).some((span) => 337 span.textContent.trim().toLowerCase() === selected 338 ); 339 article.hidden = !hasSelectedTag; 340 }); 341 } 342 343 filter.addEventListener('change', applyFilter); 344 applyFilter(); 345 346 articles.forEach((article) => { 347 const showNotesButton = Array.from(article.querySelectorAll('button')).find((button) => 348 /show notes/i.test(button.textContent || '') 349 ); 350 351 if (showNotesButton) { 352 const onclick = showNotesButton.getAttribute('onclick') || ''; 353 const match = onclick.match(/toggleNotes\(['"]([^'"]+)['"]\)/); 354 if (match) { 355 const panelId = match[1]; 356 const panel = document.getElementById(panelId); 357 showNotesButton.setAttribute('aria-controls', panelId); 358 showNotesButton.setAttribute( 359 'aria-expanded', 360 panel && !panel.classList.contains('hidden') ? 'true' : 'false' 361 ); 362 363 showNotesButton.addEventListener('click', () => { 364 requestAnimationFrame(() => { 365 if (!panel) return; 366 showNotesButton.setAttribute( 367 'aria-expanded', 368 panel.classList.contains('hidden') ? 'false' : 'true' 369 ); 370 }); 371 }); 372 } 373 374 const showNotesIcon = showNotesButton.querySelector('svg'); 375 if (showNotesIcon) showNotesIcon.setAttribute('aria-hidden', 'true'); 376 } 377 378 const listenButton = Array.from(article.querySelectorAll('button')).find((button) => 379 /^\s*listen\s*$/i.test(button.textContent || '') 380 ); 381 if (listenButton) { 382 listenButton.disabled = true; 383 listenButton.setAttribute('aria-disabled', 'true'); 384 listenButton.setAttribute('aria-label', 'Listen â not yet available'); 385 listenButton.setAttribute('title', 'Audio not yet available'); 386 listenButton.classList.add('opacity-50', 'cursor-not-allowed'); 387 const icon = listenButton.querySelector('svg'); 388 if (icon) icon.setAttribute('aria-hidden', 'true'); 389 } 390 391 const checklistLink = Array.from(article.querySelectorAll('a')).find((link) => 392 /prevention checklist/i.test(link.textContent || '') && link.getAttribute('href') === '#' 393 ); 394 if (checklistLink) { 395 checklistLink.removeAttribute('href'); 396 checklistLink.setAttribute('aria-disabled', 'true'); 397 checklistLink.setAttribute('title', 'Prevention checklist not yet available'); 398 checklistLink.classList.add('opacity-50', 'cursor-not-allowed'); 399 const icon = checklistLink.querySelector('svg'); 400 if (icon) icon.setAttribute('aria-hidden', 'true'); 401 } 402 }); 403 } 404 405 if (document.readyState === 'loading') { 406 document.addEventListener('DOMContentLoaded', initialise, { once: true }); 407 } else { 408 initialise(); 409 } 410 } 411 412 // Every StaticForms form displays the same short privacy notice. 413 function ensurePrivacyNotice(form) { 414 if (form.querySelector('.form-privacy-note')) return; 415 416 const note = document.createElement('p'); 417 note.className = 'form-privacy-note text-xs text-gray-400 mt-1'; 418 note.innerHTML = 'Consistent with our <a href="https://www.dontfail.ai/privacy-policy.html" class="underline hover:text-white">Privacy Policy</a>.'; 419 420 const status = form.querySelector('.form-status'); 421 if (status) status.insertAdjacentElement('afterend', note); 422 else form.appendChild(note); 423 } 424 425 // Keep the newsletter button aligned to the 48px email field even when 426 // status/privacy text makes the neighbouring column taller. 427 function normaliseNewsletterSubmitButton(form) { 428 if (form.getAttribute('aria-label') !== 'dontfail.ai newsletter signup') return; 429 430 const submitButton = form.querySelector('button[type="submit"], input[type="submit"]'); 431 if (!submitButton) return; 432 433 submitButton.style.height = '48px'; 434 submitButton.style.minHeight = '48px'; 435 submitButton.style.alignSelf = 'flex-start'; 436 } 437 438 function showFeedback(form, type) { 439 const status = form.querySelector('.form-status'); 440 if (!status) return; 441 442 const successMessage = form.dataset.successMessage || 'Thanks - your submission has been received.'; 443 const errorMessage = form.dataset.errorMessage || 'Something went wrong. Please try again.'; 444 445 status.textContent = type === 'success' ? successMessage : errorMessage; 446 status.hidden = false;
447 status.classList.remove('text-green-300', 'text-red-300'); 448 status.classList.add(type === 'success' ? 'text-green-300' : 'text-red-300'); 449 if (!status.hasAttribute('tabindex')) status.setAttribute('tabindex', '-1'); 450 if (typeof status.focus === 'function') status.focus(); 451 } 452 453 function pushFormAnalyticsEvent(eventName, parameters = {}) { 454 if (typeof window.dontfailPushAnalyticsEvent === 'function') { 455 window.dontfailPushAnalyticsEvent(eventName, parameters); 456 return; 457 } 458 459 // Preserve the event if analytics.js is delayed; GTM will process the queue when available. 460 window.dataLayer = window.dataLayer || []; 461 window.dataLayer.push({ 462 event: eventName, 463 ...parameters 464 }); 465 } 466 467 function trackSuccessfulFormSubmission(form, submittedData) { 468 const formType = String(submittedData.formType || '').trim(); 469 const subject = String(submittedData.subject || '').trim(); 470 const isNewsletter = form.getAttribute('aria-label') === 'dontfail.ai newsletter signup' || formType === 'Newsletter signup'; 471 const isFailSubmission = form.id === 'fail-submission-form' || /submit.*fail/i.test(subject) || /fail submission/i.test(formType); 472 473 // Never send email addresses or submitted form content into analytics. 474 if (isNewsletter) { 475 pushFormAnalyticsEvent('newsletter_signup', { 476 page_path: window.location.pathname 477 }); 478 } else if (isFailSubmission) { 479 pushFormAnalyticsEvent('submit_a_fail', { 480 page_path: window.location.pathname 481 }); 482 } 483 } 484 485 async function handleStaticForm(form) { 486 if (!form.checkValidity()) { 487 form.reportValidity(); 488 return; 489 } 490 491 const honeypot = form.querySelector('[name="honeypot"], [name="website"]'); 492 if (honeypot && honeypot.value) return; 493 494 const accessKeyInput = form.querySelector('input[name="accessKey"]'); 495 const accessKey = accessKeyInput ? accessKeyInput.value.trim() : ''; 496 if (!accessKey) { 497 showFeedback(form, 'error'); 498 return; 499 } 500 501 const submitButton = form.querySelector('button[type="submit"], input[type="submit"]'); 502 const originalLabel = submitButton 503 ? (submitButton.tagName === 'INPUT' ? submitButton.value : submitButton.textContent) 504 : ''; 505 506 if (submitButton) { 507 submitButton.disabled = true; 508 submitButton.setAttribute('aria-busy', 'true'); 509 if (submitButton.tagName === 'INPUT') submitButton.value = 'Sendingâ¦'; 510 else submitButton.textContent = 'Sendingâ¦'; 511 } 512 513 const data = {}; 514 new FormData(form).forEach((value, key) => { 515 if (data[key]) data[key] = [].concat(data[key], value); 516 else data[key] = value; 517 }); 518 519 try { 520 const response = await fetch(staticFormsEndpoint, { 521 method: 'POST', 522 headers: { 523 'Content-Type': 'application/json', 524 Accept: 'application/json' 525 }, 526 body: JSON.stringify(data) 527 }); 528 529 let result = null; 530 try { result = await response.json(); } catch (_err) {} 531 532 if (!response.ok || !result || result.success !== true) { 533 throw new Error('Submission failed'); 534 } 535 536 trackSuccessfulFormSubmission(form, data); 537 form.reset(); 538 if (accessKeyInput) accessKeyInput.value = accessKey; 539 showFeedback(form, 'success'); 540 } catch (_err) { 541 showFeedback(form, 'error'); 542 } finally { 543 if (submitButton) { 544 submitButton.disabled = false; 545 submitButton.removeAttribute('aria-busy'); 546 if (submitButton.tagName === 'INPUT') submitButton.value = originalLabel; 547 else submitButton.textContent = originalLabel; 548 } 549 } 550 } 551 552 initialiseMobileNavigation(); 553 ensureHallOfFameFooter(); 554 ensureHallOfFameModalAccessibility(); 555 ensureEpisodesInteractionIntegrity(); 556 557 document.querySelectorAll('form[data-staticforms="true"]').forEach((form) => { 558 ensurePrivacyNotice(form); 559 normaliseNewsletterSubmitButton(form); 560 561 form.addEventListener('submit', (event) => { 562 event.preventDefault(); 563 handleStaticForm(form); 564 }); 565 }); 566})();
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.