diff --git a/_includes/base.njk b/_includes/base.njk index 66291e0..edecaaa 100644 --- a/_includes/base.njk +++ b/_includes/base.njk @@ -21,10 +21,8 @@ - - - - + + {%- if pageCss %} @@ -33,9 +31,7 @@ {%- endif %} {%- block head %}{% endblock %} - {%- if recaptcha %} - - {%- endif %} + {#- reCAPTCHA is lazy-loaded by form.js on first form interaction (see recaptcha pages) #} diff --git a/assets/css/main.css b/assets/css/main.css index 2eb0c2b..2735d02 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -2,6 +2,29 @@ Sea Haven Industries — Shared Styles ══════════════════════════════════════════════ */ +/* ── Self-hosted fonts (latin subset) ── */ +@font-face { + font-family: 'DM Serif Display'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/assets/fonts/dm-serif-display.woff2') format('woff2'); +} +@font-face { + font-family: 'DM Serif Display'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/assets/fonts/dm-serif-display-italic.woff2') format('woff2'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; /* variable */ + font-display: swap; + src: url('/assets/fonts/inter.woff2') format('woff2'); +} + /* ── Reset & Tokens ── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } diff --git a/assets/fonts/dm-serif-display-italic.woff2 b/assets/fonts/dm-serif-display-italic.woff2 new file mode 100644 index 0000000..45d19db Binary files /dev/null and b/assets/fonts/dm-serif-display-italic.woff2 differ diff --git a/assets/fonts/dm-serif-display.woff2 b/assets/fonts/dm-serif-display.woff2 new file mode 100644 index 0000000..7c9fa83 Binary files /dev/null and b/assets/fonts/dm-serif-display.woff2 differ diff --git a/assets/fonts/inter.woff2 b/assets/fonts/inter.woff2 new file mode 100644 index 0000000..91dc3e8 Binary files /dev/null and b/assets/fonts/inter.woff2 differ diff --git a/assets/js/form.js b/assets/js/form.js index 136829f..7694e8c 100644 --- a/assets/js/form.js +++ b/assets/js/form.js @@ -1,14 +1,44 @@ -// Basin AJAX submission with reCAPTCHA v3 for the contact form and job apply forms. +// Basin AJAX submission with lazily-loaded reCAPTCHA v3 for the contact form +// and job apply forms. The reCAPTCHA script is not loaded until the visitor +// first interacts with a form (or submits), keeping it off the initial page load. (function () { "use strict"; const SITE_KEY = "6Les66kUAAAAANyLrgkl7iuN4JUpNlB5upaMovI4"; - // Contact page wraps the form in a .contact-form div; services puts the class - // on the
itself; job pages use .apply-form. Match all three. const forms = document.querySelectorAll(".contact-form form, form.contact-form, form.apply-form"); if (!forms.length) return; + // ── Lazy reCAPTCHA loader ────────────────────────────────────── + let loadStarted = false; + function loadRecaptcha() { + if (loadStarted) return; + loadStarted = true; + const s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + SITE_KEY; + s.async = true; + document.head.appendChild(s); + } + // Resolves once grecaptcha is ready; loads the script if not already loading. + function readyRecaptcha() { + loadRecaptcha(); + return new Promise((resolve, reject) => { + const start = Date.now(); + (function check() { + if (window.grecaptcha && window.grecaptcha.execute) { + window.grecaptcha.ready(resolve); + } else if (Date.now() - start > 10000) { + reject(new Error("reCAPTCHA failed to load")); + } else { + setTimeout(check, 100); + } + })(); + }); + } + forms.forEach((form) => { + // Warm the loader as soon as the user engages with the form. + form.addEventListener("focusin", loadRecaptcha, { once: true }); + const success = document.getElementById("form-success"); const statusEl = document.getElementById("form-status"); // optional role="status" region const tokenField = form.querySelector("#g-recaptcha-response"); @@ -29,7 +59,8 @@ announce(""); try { - const token = await grecaptcha.execute(SITE_KEY, { action: "submit" }); + await readyRecaptcha(); + const token = await window.grecaptcha.execute(SITE_KEY, { action: "submit" }); if (tokenField) tokenField.value = token; const res = await fetch(form.action, { method: "POST",