'use strict'; (function () { var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var hasObserver = 'IntersectionObserver' in window; // Scroll reveal: fade and rise once, staggered 80ms between siblings. function initReveal() { var items = Array.prototype.slice.call(document.querySelectorAll('.reveal')); if (!items.length) { return; } if (reducedMotion || !hasObserver) { items.forEach(function (el) { el.classList.add('visible'); }); return; } items.forEach(function (el) { var siblings = [el]; if (el.parentElement) { siblings = Array.prototype.filter.call(el.parentElement.children, function (child) { return child.classList.contains('reveal'); }); } var position = siblings.indexOf(el); if (position > 0) { el.style.transitionDelay = Math.min(position, 6) * 80 + 'ms'; } }); var observer = new IntersectionObserver( function (entries, obs) { entries.forEach(function (entry) { if (entry.isIntersecting) { entry.target.classList.add('visible'); obs.unobserve(entry.target); } }); }, { threshold: 0.15, rootMargin: '0px 0px -5% 0px' } ); items.forEach(function (el) { observer.observe(el); }); } // Drawn elements: dimension lines, the process line and the quote band light patch. // Each one fires once on scroll-in via the .is-drawn class on the element itself. // The .js-ready class on arms the "not yet drawn" start state in style.css; // it is only set when the animation can actually run (observer present, motion allowed), // so reduced-motion visitors and old browsers get the final state with no hidden content. function initDrawn() { var targets = Array.prototype.slice.call( document.querySelectorAll('.motif-dimension, .process, .process__line, .quote-band') ); function settle(el) { el.classList.add('is-drawn'); } if (reducedMotion || !hasObserver) { targets.forEach(settle); return; } document.documentElement.classList.add('js-ready'); var observer = new IntersectionObserver( function (entries, obs) { entries.forEach(function (entry) { if (entry.isIntersecting) { settle(entry.target); obs.unobserve(entry.target); } }); }, { threshold: 0.3 } ); targets.forEach(function (el) { observer.observe(el); }); } // Scroll-to-top button. function initScrollToTop() { var button = document.getElementById('scroll-to-top-btn'); if (!button) { return; } function update() { button.classList.toggle('visible', window.scrollY > 600); } window.addEventListener('scroll', update, { passive: true }); update(); button.addEventListener('click', function () { window.scrollTo({ top: 0, behavior: reducedMotion ? 'auto' : 'smooth' }); }); } // Tjenester: highlight the matching entry in the sticky index. function initServiceIndex() { var links = Array.prototype.slice.call(document.querySelectorAll('.service-index__link')); var blocks = Array.prototype.slice.call(document.querySelectorAll('.service-block')); if (!links.length || !blocks.length) { return; } function revealChip(link) { var strip = link.closest('.service-index'); if (!strip || strip.scrollWidth <= strip.clientWidth) { return; } var stripRect = strip.getBoundingClientRect(); var linkRect = link.getBoundingClientRect(); var offset = linkRect.left - stripRect.left - (stripRect.width - linkRect.width) / 2; strip.scrollLeft += offset; } function setActive(id) { links.forEach(function (link) { var isMatch = link.getAttribute('href') === '#' + id; link.classList.toggle('is-active', isMatch); if (isMatch) { link.setAttribute('aria-current', 'true'); revealChip(link); } else { link.removeAttribute('aria-current'); } }); } if (!hasObserver) { setActive(blocks[0].id); return; } var visible = {}; var observer = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { visible[entry.target.id] = entry.isIntersecting ? entry.intersectionRatio : 0; }); var bestId = null; var bestRatio = 0; blocks.forEach(function (block) { var ratio = visible[block.id] || 0; if (ratio > bestRatio) { bestRatio = ratio; bestId = block.id; } }); if (bestId) { setActive(bestId); } }, { rootMargin: '-20% 0px -45% 0px', threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] } ); blocks.forEach(function (block) { if (block.id) { observer.observe(block); } }); var initialHash = window.location.hash ? window.location.hash.slice(1) : ''; var hasInitial = blocks.some(function (block) { return block.id === initialHash; }); setActive(hasInitial ? initialHash : blocks[0].id); } function isValidEmail(value) { if (value.indexOf(' ') !== -1) { return false; } var at = value.indexOf('@'); var lastDot = value.lastIndexOf('.'); if (at < 1) { return false; } if (value.indexOf('@', at + 1) !== -1) { return false; } if (lastDot < at + 2) { return false; } return value.length - lastDot >= 3; } // Kontakt: form validation and honest submit handling. function initContactForm() { var form = document.getElementById('contact-form'); if (!form) { return; } var success = document.getElementById('form-success'); var resetButton = document.getElementById('form-send-another'); var submitButton = form.querySelector('button[type=submit]'); var fields = { navn: { input: document.getElementById('navn'), error: document.getElementById('navn-error'), validate: function (value) { return value.trim() ? '' : 'Skriv inn navnet ditt.'; }, }, epost: { input: document.getElementById('epost'), error: document.getElementById('epost-error'), validate: function (value) { var trimmed = value.trim(); if (!trimmed) { return 'Skriv inn e-postadressen din.'; } if (!isValidEmail(trimmed)) { return 'E-postadressen ser ikke riktig ut.'; } return ''; }, }, melding: { input: document.getElementById('melding'), error: document.getElementById('melding-error'), validate: function (value) { return value.trim() ? '' : 'Fortell kort om rommet og hva du vil gjøre.'; }, }, }; var order = ['navn', 'epost', 'melding']; function setDescribedBy(field, withError) { var existing = field.baseDescribedBy || ''; var ids = existing ? existing.split(' ') : []; if (withError && ids.indexOf(field.error.id) === -1) { ids.push(field.error.id); } if (ids.length) { field.input.setAttribute('aria-describedby', ids.join(' ')); } else { field.input.removeAttribute('aria-describedby'); } } function checkField(key) { var field = fields[key]; if (!field.input || !field.error) { return true; } var message = field.validate(field.input.value); if (message) { field.error.textContent = '! ' + message; field.error.hidden = false; field.input.setAttribute('aria-invalid', 'true'); setDescribedBy(field, true); return false; } field.error.textContent = ''; field.error.hidden = true; field.input.removeAttribute('aria-invalid'); setDescribedBy(field, false); return true; } order.forEach(function (key) { var field = fields[key]; if (!field.input || !field.error) { console.error('[contact-form] missing input or error element for field: ' + key); return; } field.baseDescribedBy = field.input.getAttribute('aria-describedby') || ''; field.input.addEventListener('blur', function () { checkField(key); }); field.input.addEventListener('input', function () { if (!field.error.hidden) { checkField(key); } }); }); function clearErrors() { order.forEach(function (key) { var field = fields[key]; if (!field.input || !field.error) { return; } field.error.textContent = ''; field.error.hidden = true; field.input.removeAttribute('aria-invalid'); setDescribedBy(field, false); }); } function isMailto(action) { return action.toLowerCase().indexOf('mailto:') === 0; } function buildMailto(action) { var address = action.slice(7).split('?')[0] || 'post@lysogrom.no'; var typeField = document.getElementById('prosjekttype'); var phoneField = document.getElementById('telefon'); var type = typeField && typeField.value ? typeField.value : 'Annet'; var phone = phoneField && phoneField.value.trim() ? phoneField.value.trim() : 'ikke oppgitt'; var body = 'Navn: ' + fields.navn.input.value.trim() + '\n' + 'E-post: ' + fields.epost.input.value.trim() + '\n' + 'Telefon: ' + phone + '\n' + 'Type prosjekt: ' + type + '\n\n' + fields.melding.input.value.trim(); return ( 'mailto:' + address + '?subject=' + encodeURIComponent('Henvendelse om første møte: ' + type) + '&body=' + encodeURIComponent(body) ); } function showSuccess() { if (!success) { return; } var note = success.querySelector('p'); if (note) { note.textContent = 'E-postprogrammet ditt åpnes nå med meldingen ferdig utfylt. Trykk send der. ' + 'Åpnet det seg ikke, skriv til post@lysogrom.no eller ring 73 50 22 10.'; } form.hidden = true; success.hidden = false; success.classList.add('is-visible'); if (!success.hasAttribute('tabindex')) { success.setAttribute('tabindex', '-1'); } success.focus(); } form.addEventListener('submit', function (event) { var firstInvalid = null; order.forEach(function (key) { if (!fields[key].input || !fields[key].error) { return; } if (!checkField(key) && !firstInvalid) { firstInvalid = fields[key].input; } }); if (firstInvalid) { event.preventDefault(); firstInvalid.focus(); return; } var action = form.getAttribute('action') || ''; if (action && !isMailto(action)) { return; } event.preventDefault(); if (submitButton) { submitButton.disabled = true; } try { window.location.href = buildMailto(action); showSuccess(); } finally { if (submitButton) { submitButton.disabled = false; } } }); if (resetButton) { resetButton.addEventListener('click', function () { form.reset(); clearErrors(); if (success) { success.hidden = true; success.classList.remove('is-visible'); } form.hidden = false; if (fields.navn.input) { fields.navn.input.focus(); } }); } else { console.error('[contact-form] #form-send-another not found'); } } function init() { initReveal(); initDrawn(); initScrollToTop(); initServiceIndex(); initContactForm(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); /* ============================================================ NAVIGATION SCAFFOLD — DO NOT EDIT (managed by Orchestra) ============================================================ */ (function () { var nav = document.getElementById('ns-nav'); var linksContainer = document.getElementById('ns-links'); var burger = document.getElementById('ns-hamburger'); var overlay = document.getElementById('ns-overlay'); if (!nav || !linksContainer || !burger) return; var isOpen = false; function openMenu() { isOpen = true; linksContainer.classList.add('open'); if (overlay) { overlay.classList.add('open'); overlay.setAttribute('aria-hidden', 'false'); } burger.setAttribute('aria-expanded', 'true'); document.body.style.overflow = 'hidden'; } function closeMenu() { isOpen = false; linksContainer.classList.remove('open'); if (overlay) { overlay.classList.remove('open'); overlay.setAttribute('aria-hidden', 'true'); } burger.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; } function toggleMenu() { if (isOpen) closeMenu(); else openMenu(); } // Event listeners burger.addEventListener('click', toggleMenu); if (overlay) overlay.addEventListener('click', closeMenu); linksContainer.querySelectorAll('a').forEach(function (link) { link.addEventListener('click', function () { if (window.innerWidth <= 768) closeMenu(); }); }); // Keyboard — Escape closes menu document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen) { closeMenu(); burger.focus(); } }); // Resize safety — prevents double nav when resizing from mobile to desktop window.addEventListener( 'resize', function () { if (window.innerWidth > 768 && isOpen) { closeMenu(); } }, { passive: true } ); // Scroll effect — transparent to solid background window.addEventListener( 'scroll', function () { nav.classList.toggle('scrolled', window.scrollY > 80); }, { passive: true } ); // Active link highlighting via IntersectionObserver var sections = []; linksContainer.querySelectorAll('a[href^="#"]').forEach(function (a) { var id = a.getAttribute('href').slice(1); var el = document.getElementById(id); if (el) sections.push({ el: el, link: a }); }); if (sections.length && 'IntersectionObserver' in window) { var obs = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { linksContainer.querySelectorAll('a').forEach(function (a) { a.classList.remove('active'); }); sections.forEach(function (s) { if (s.el === entry.target) s.link.classList.add('active'); }); } }); }, { rootMargin: '-20% 0px -70% 0px', threshold: 0 } ); sections.forEach(function (s) { obs.observe(s.el); }); } })(); /* ============================================================ NAV CONTROLS SCAFFOLD — DO NOT EDIT (managed by Orchestra) ------------------------------------------------------------ Wires the optional right-slot controls (search trigger, theme toggle) to their behaviour. Both branches are guarded by an element-existence check, so this block is inert on builds that don't opt in to controls. TRIGGERED-COMPONENT INVARIANT (see core/nav_scaffold.py docstring): • #ns-search-modal defaults to `hidden` + CSS `display:none`. • Open state is set ONLY here, in response to a user click. • Close paths: backdrop click, close button, Escape key. • Re-opening focus returns to the trigger for keyboard users. Do not duplicate this logic in script.js. Do not pre-open the modal in markup. Open state belongs to the user, not the template. ============================================================ */ (function () { // ── Search modal ─────────────────────────────────────────────────────── var trigger = document.getElementById('ns-search-trigger'); var modal = document.getElementById('ns-search-modal'); if (trigger && modal) { var input = modal.querySelector('#ns-search-input'); var closers = modal.querySelectorAll('[data-ns-search-close]'); // Function EXPRESSIONS, not declarations. A `function foo() {}` inside an // `if` block is `no-inner-declarations`, and this template shipped three of // them into every static_site and nye_eventyr build — where the Developer // is forbidden to fix them, because this is the reserved `ns-` namespace. // Measured on marstrand-bokbinderi: all 6 of its eslint errors were in this // file's output, the build was filed `partial` for them, and a --bugfix run // then burned all three of its capped attempts on code it may not touch. // Both are declared before the listeners below that call them, so the // behaviour is identical. var openSearch = function () { modal.removeAttribute('hidden'); modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false'); trigger.setAttribute('aria-expanded', 'true'); document.body.style.overflow = 'hidden'; if (input) setTimeout(function () { input.focus(); }, 30); }; var closeSearch = function () { modal.classList.remove('open'); modal.setAttribute('hidden', ''); modal.setAttribute('aria-hidden', 'true'); trigger.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; trigger.focus(); }; trigger.addEventListener('click', openSearch); closers.forEach(function (el) { el.addEventListener('click', closeSearch); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && modal.classList.contains('open')) closeSearch(); }); } // ── Theme toggle ─────────────────────────────────────────────────────── // Sets `data-theme` on ; consumer CSS variables read from that // attribute (`:root[data-theme="light"] { ... }`) to recolour the site. // Persisted in localStorage so the choice survives reloads. var themeBtn = document.getElementById('ns-theme-toggle'); if (themeBtn) { var STORAGE_KEY = 'orch-theme'; // Expression, not a declaration — see the openSearch note below. var applyTheme = function (t) { document.documentElement.setAttribute('data-theme', t); themeBtn.setAttribute('aria-pressed', t === 'light' ? 'true' : 'false'); }; var stored = null; try { stored = localStorage.getItem(STORAGE_KEY); } catch (_) { /* storage blocked (private mode / 3rd-party cookies off) */ } applyTheme(stored === 'light' ? 'light' : 'dark'); themeBtn.addEventListener('click', function () { var current = document.documentElement.getAttribute('data-theme'); var next = current === 'light' ? 'dark' : 'light'; try { localStorage.setItem(STORAGE_KEY, next); } catch (_) { /* storage blocked: the theme still applies for this page */ } applyTheme(next); }); } })(); /* END NAVIGATION SCAFFOLD */ /* ============================================================ NAV PAGE-ACTIVE SCAFFOLD — DO NOT EDIT (managed by Orchestra) Multi-page builds: mark the current page's nav link .active by matching normalised pathnames. Works under file://, http(s)://, index/root, trailing slash, and extension-less routes. ============================================================ */ (function () { var links = document.getElementById('ns-links'); if (!links) return; function pageKey(p) { try { p = decodeURIComponent(p || ''); } catch (_) { /* malformed %-escape: fall through with the raw path */ } p = p.split('#')[0].split('?')[0].replace(/\\/g, '/'); var seg = p.substring(p.lastIndexOf('/') + 1).toLowerCase(); if (seg === '' || seg === 'index.html' || seg === 'home.html') return 'index.html'; if (seg.indexOf('.') === -1) seg += '.html'; return seg; } var current = pageKey(window.location.pathname || window.location.href); links.querySelectorAll('a').forEach(function (a) { var href = a.getAttribute('href') || ''; if (href.charAt(0) === '#') return; // in-page anchor, not a page link if (pageKey(href) === current) { a.classList.add('active'); a.setAttribute('aria-current', 'page'); } else { a.classList.remove('active'); a.removeAttribute('aria-current'); } }); })(); /* END NAV PAGE-ACTIVE SCAFFOLD */ /* ============================================================ FOOTER SCAFFOLD — newsletter (managed by Orchestra) No backend on a static site: validate, confirm, done. A real endpoint is wired later by giving the form an action attribute. ============================================================ */ document.querySelectorAll('form[data-ns-newsletter]').forEach(function (form) { form.addEventListener('submit', function (event) { if (form.getAttribute('action')) return; // real endpoint wired — let it post event.preventDefault(); var input = form.querySelector('input[type="email"]'); if (input && !input.checkValidity()) { input.reportValidity(); return; } var note = form.querySelector('[data-ns-newsletter-note]'); if (note) { note.hidden = false; note.textContent = note.getAttribute('data-success') || note.textContent; } form.reset(); }); }); /* END FOOTER SCAFFOLD */