1// The prototype's own page script, unchanged. Runs once after mount via 2// src/usePageBoot.ts. Move behaviour into components as you take them over. 3 4; 5const directions={editorial:{title:'The editorial voice.',reason:'Confident type. A clear point of view.',font:'Bold / 80 / tight',layout:'Asymmetric editorial',name:'01 / EDITORIAL SYSTEM',note:'Oversized headlines, compact supporting copy and an asymmetric image grid.',weight:650},immersive:{title:'The spatial experience.',reason:'Let the work make the introduction.',font:'Regular / 64 / open',layout:'Immersive image field',name:'02 / IMMERSIVE SYSTEM',note:'Full-width photography, restrained typography and open spacing.',weight:400},systematic:{title:'The considered index.',reason:'Ordered projects. Room for the details.',font:'Medium / 48 / compact',layout:'Ordered project index',name:'03 / SYSTEMATIC SYSTEM',note:'Numbered projects, consistent columns and a precise type hierarchy.',weight:500}}; 6 7const demoCatalog={editorial:{label:'Editorial',file:'prototype.html'},immersive:{label:'Immersive',file:'prototype.html'},systematic:{label:'Systematic',file:'prototype.html'},fieldwork:{label:'Fieldwork',file:'product-example.html'},form:{label:'Form',file:'campaign-example.html'},solstice:{label:'Solstice',file:'solstice-example.html'},lumen:{label:'Lumen',file:'lumen-example.html'}}; 8let selected='editorial',spacing='original',currentDocument='prototype.html'; 9const frame=document.querySelector('#prototype-frame'); 10const openLink=document.querySelector('#open-prototype'); 11const prototypeBase=openLink.getAttribute('href').split('?')[0]; 12const prototypeSelect=document.querySelector('#prototype-select'); 13function demoURL(key){const d=demoCatalog[key];return prototypeBase.replace(/prototype\.html$/,d.file)+(d.file==='prototype.html'?'?direction='+key+'&spacing='+spacing:'')} 14function updateOpenLink(){openLink.href=demoURL(selected)} 15function sendState(){if(demoCatalog[selected].file!=='prototype.html'||!frame.contentWindow)return;const target=location.origin==='null'?'*':location.origin;frame.contentWindow.postMessage({type:'northlit-direction',direction:selected},target);frame.contentWindow.postMessage({type:'northlit-spacing',spacing},target)} 16function choose(key){ 17 if(!demoCatalog[key])return;selected=key;const d=demoCatalog[key]; 18 if(directions[key])updateShowcase(key); 19 prototypeSelect.value=key; 20 document.querySelector('#prototype-status').textContent=d.label+' selected. Open a prototype to explore.'; 21 document.querySelector('#lightbox-title').textContent='Prototype viewer'; 22 frame.title='Interactive '+d.label+' prototype'; 23 updateOpenLink(); 24 if(currentDocument!==d.file){ 25 currentDocument=d.file; 26 if(window.NORTHLIT_DEMOS?.[d.file]){frame.removeAttribute('src');frame.srcdoc=window.NORTHLIT_DEMOS[d.file]} 27 else{frame.removeAttribute('srcdoc');frame.src=demoURL(key)} 28 }else sendState(); 29} 30prototypeSelect.addEventListener('change',()=>choose(prototypeSelect.value)); 31document.querySelectorAll('[data-hero-choice]').forEach(button=>button.addEventListener('click',()=>choose(button.dataset.heroChoice))); 32frame.addEventListener('load',sendState); 33function followHash(){if(location.hash==='#prototype')openViewer(selected)} 34addEventListener('hashchange',followHash); 35document.querySelectorAll('[data-device]').forEach(button=>button.addEventListener('click',()=>{const stage=document.querySelector('.preview-stage');stage.classList.toggle('mobile',button.dataset.device==='mobile');stage.classList.toggle('tablet',button.dataset.device==='tablet');document.querySelectorAll('[data-device]').forEach(other=>other.setAttribute('aria-pressed',String(other===button)))})); 36 37// Decode before changing layers; rapid selections always resolve to the last choice. 38let showcaseRequest=0; 39async function updateShowcase(key,automatic=false){ 40 const stage=document.querySelector('.direction-showcase');if(!stage)return; 41 const images=[...stage.querySelectorAll('[data-direction-image]')]; 42 const incoming=images.find(img=>img.dataset.directionImage===key);if(!incoming)return; 43 const request=++showcaseRequest; 44 try{if(incoming.decode)await incoming.decode();else if(!incoming.complete)await new Promise((resolve,reject)=>{incoming.addEventListener('load',resolve,{once:true});incoming.addEventListener('error',reject,{once:true})});} 45 catch{if(request===showcaseRequest)stage.querySelector('#showcase-status').textContent='That image could not load. Please try another direction.';return;} 46 if(request!==showcaseRequest)return;
47 images.forEach(img=>{const on=img===incoming;img.classList.toggle('is-active',on);img.setAttribute('aria-hidden',String(!on))}); 48 stage.querySelectorAll('[data-hero-choice]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.heroChoice===key))); 49 const names={editorial:'01 / Editorial',immersive:'02 / Immersive',systematic:'03 / Systematic'}; 50 stage.querySelector('#showcase-title').textContent=names[key]; 51 if(!automatic)stage.querySelector('#showcase-status').textContent=names[key]+' direction displayed.'; 52} 53 54// Native dialog makes the page inert and keeps keyboard focus in the viewer. 55const lightbox=document.querySelector('#prototype-lightbox'); 56const closeLightbox=document.querySelector('#close-lightbox'); 57let viewerTrigger=null,viewerScroll=0,bodyOverflow='',rootOverflow=''; 58function openViewer(key,trigger=document.activeElement){ 59 if(!demoCatalog[key])return; 60 if(!lightbox.open){ 61 viewerTrigger=trigger;viewerScroll=window.scrollY;bodyOverflow=document.body.style.overflow;rootOverflow=document.documentElement.style.overflow; 62 lightbox.showModal();document.body.style.overflow='hidden';document.documentElement.style.overflow='hidden'; 63 } 64 choose(key);closeLightbox.focus({preventScroll:true}); 65}
66function closeViewer(){if(lightbox.open)lightbox.close()} 67closeLightbox.addEventListener('click',closeViewer); 68lightbox.addEventListener('cancel',event=>{event.preventDefault();closeViewer()}); 69lightbox.addEventListener('close',()=>{ 70 document.body.style.overflow=bodyOverflow;document.documentElement.style.overflow=rootOverflow; 71 if(viewerTrigger?.isConnected)viewerTrigger.focus({preventScroll:true}); 72 window.scrollTo({top:viewerScroll,behavior:'instant'}); 73}); 74document.querySelectorAll('[data-open-prototype]').forEach(button=>button.addEventListener('click',event=>{event.preventDefault();openViewer(button.dataset.openPrototype,button)})); 75document.querySelectorAll('[data-stage-link]').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();openViewer(selected,link)})); 76addEventListener('message',event=>{ 77 if(event.source!==frame.contentWindow||event.origin!==location.origin)return; 78 if(event.data?.type==='northlit-close-viewer')closeViewer(); 79}); 80followHash(); 81 82 83// Rotation affects only the image showcase, never the working prototype. 84function initShowcaseRotation(){ 85 const stage=document.querySelector('.direction-showcase');if(!stage)return; 86 const order=['editorial','immersive','systematic']; 87 const motion=matchMedia('(prefers-reduced-motion: reduce)'); 88 let playing=!motion.matches,visible=true,hovered=false,timer=null; 89 function allowed(){return playing&&visible&&!hovered&&!document.hidden&&!lightbox.open&&!stage.contains(document.activeElement)} 90 function schedule(){clearTimeout(timer);timer=null;if(!allowed())return;timer=setTimeout(async()=>{timer=null;if(!allowed())return;const key=stage.querySelector('.direction-image.is-active')?.dataset.directionImage||'editorial';await updateShowcase(order[(order.indexOf(key)+1)%order.length],true);schedule()},4000)} 91 stage.addEventListener('pointerenter',event=>{if(event.pointerType==='mouse'){hovered=true;schedule()}}); 92 stage.addEventListener('pointerleave',()=>{hovered=false;schedule()}); 93 stage.addEventListener('focusin',schedule); 94 stage.addEventListener('focusout',()=>queueMicrotask(schedule)); 95 stage.querySelectorAll('[data-hero-choice]').forEach(button=>button.addEventListener('click',schedule)); 96 document.addEventListener('visibilitychange',schedule); 97 lightbox.addEventListener('toggle',schedule);lightbox.addEventListener('close',schedule); 98 motion.addEventListener('change',()=>{playing=!motion.matches;schedule()}); 99 if('IntersectionObserver' in window)new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;schedule()},{threshold:0}).observe(stage); 100 schedule(); 101} 102initShowcaseRotation(); 103 104// GA4 events for the landing's galleries and films. Taxonomy lives in 105// lib/ga.ts; this is the static page's copy of its `track`: a no-op wherever 106// gtag isn't loaded (dev/preview builds, ad blockers) and never throws. 107function nlTrack(name, params) { try { if (typeof window.gtag === 'function') window.gtag('event', name, params); } catch {} } 108// Wires one <video> for GA with GA4's own video param names, so reports line 109// up with any embedded player: video_start on the first play, video_progress 110// once each at 25/50/75%, video_complete at the end. `placement` says which 111// player â the hero overlay or the gallery slide. 112function nlTrackVideo(video, placement) { 113 const snapshot = () => ({ 114 placement, 115 video_provider: 'html5', 116 video_title: video.getAttribute('aria-label') || 'Northlit promo', 117 video_url: video.currentSrc || video.src, 118 video_duration: Math.round(video.duration || 0), 119 video_current_time: Math.round(video.currentTime || 0), 120 video_percent: video.duration ? Math.round(100 * video.currentTime / video.duration) : 0, 121 }); 122 let started = false; 123 const reached = new Set(); 124 // Both films are preload="none", so the first play usually lands before the 125 // duration is known; hold video_start until it is, so the event carries it. 126 video.addEventListener('play', () => { 127 if (started) return; started = true; 128 if (video.duration) nlTrack('video_start', snapshot()); 129 else video.addEventListener('loadedmetadata', () => nlTrack('video_start', snapshot()), { once: true }); 130 }); 131 video.addEventListener('timeupdate', () => { 132 if (!video.duration) return; 133 const percent = 100 * video.currentTime / video.duration; 134 for (const mark of [25, 50, 75]) { 135 if (percent >= mark && !reached.has(mark)) { reached.add(mark); nlTrack('video_progress', { ...snapshot(), video_percent: mark }); } 136 } 137 }); 138 video.addEventListener('ended', () => nlTrack('video_complete', { ...snapshot(), video_percent: 100 })); 139 return snapshot; 140} 141 142// The brand poster gallery: cycles every 4s while on screen, unhovered and 143// unfocused; the thumbnails are the only controls â picking one shows it and 144// restarts the cycle from there. Automatic turns are silent (the live status 145// line speaks only for the reader's own picks), and no turn happens under 146// reduced motion. Same guards as the product gallery further down. 147(()=>{ 148 const gallery=document.querySelector('.brand-gallery'); 149 if(!gallery)return; 150 const buttons=[...gallery.querySelectorAll('[data-brand-choice]')]; 151 const posters=[...gallery.querySelectorAll('[data-brand-poster]')]; 152 const status=gallery.querySelector('.brand-poster-status'); 153 const reduced=matchMedia('(prefers-reduced-motion: reduce)'); 154 let index=Math.max(0,buttons.findIndex(b=>b.getAttribute('aria-pressed')==='true')),pending=0,timer,visible=true,hovered=false; 155 async function select(button,announce){ 156 const request=++pending; 157 const key=button.dataset.brandChoice; 158 const poster=posters.find(p=>p.dataset.brandPoster===key); 159 // Decode before the crossfade so it never shows a half-painted frame, but 160 // only when the bytes aren't in yet and never for more than 400ms.
161 if(poster&&!(poster.complete&&poster.naturalWidth)){try{await Promise.race([poster.decode(),new Promise(r=>setTimeout(r,400))])}catch{return}} 162 if(request!==pending)return; 163 index=buttons.indexOf(button); 164 buttons.forEach(item=>item.setAttribute('aria-pressed',String(item===button))); 165 posters.forEach(p=>{const active=p===poster;p.classList.toggle('is-active',active);p.setAttribute('aria-hidden',String(!active));}); 166 gallery.closest('.brand-intelligence')?.setAttribute('data-brand',key); 167 if(announce)status.textContent=button.getAttribute('aria-label').replace('Show ','')+' selected.'; 168 } 169 function allowed(){return !reduced.matches&&visible&&!hovered&&!document.hidden&&!gallery.contains(document.activeElement)} 170 function sync(){clearInterval(timer);if(allowed())timer=setInterval(()=>select(buttons[(index+1)%buttons.length],false),4000)} 171 // Only the reader's own picks are reported â automatic turns say nothing. 172 function report(button,method){nlTrack('gallery_select',{gallery_name:'brand_mockups',item_id:button.dataset.brandChoice,item_index:buttons.indexOf(button)+1,method})} 173 buttons.forEach((button,i)=>{ 174 button.addEventListener('click',()=>{select(button,true);sync();report(button,'thumbnail')}); 175 button.addEventListener('keydown',event=>{ 176 let next; 177 if(event.key==='ArrowRight')next=(i+1)%buttons.length; 178 else if(event.key==='ArrowLeft')next=(i-1+buttons.length)%buttons.length; 179 else if(event.key==='Home')next=0; 180 else if(event.key==='End')next=buttons.length-1; 181 else return; 182 event.preventDefault();buttons[next].focus();select(buttons[next],true);sync();report(buttons[next],'key'); 183 }); 184 }); 185 gallery.addEventListener('pointerenter',event=>{if(event.pointerType==='mouse'){hovered=true;sync()}}); 186 gallery.addEventListener('pointerleave',()=>{hovered=false;sync()}); 187 gallery.addEventListener('focusin',sync); 188 gallery.addEventListener('focusout',()=>setTimeout(sync,0)); 189 document.addEventListener('visibilitychange',sync); 190 reduced.addEventListener('change',sync); 191 if('IntersectionObserver' in window){new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;sync()},{threshold:.05}).observe(gallery)} 192 sync(); 193})(); 194 195; 196(() => { 197 const header = document.querySelector('.site-header'); 198 const toggle = header?.querySelector('.nav-toggle'); 199 const nav = header?.querySelector('#site-navigation'); 200 if (!toggle || !nav) return; 201 const mobile = matchMedia('(max-width:760px)'); 202 const label = toggle.querySelector('.nav-toggle-label'); 203 function setOpen(open) { 204 header.classList.toggle('nav-open', open); 205 toggle.setAttribute('aria-expanded', String(open)); 206 toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); 207 label.textContent = open ? 'Close' : 'Menu'; 208 } 209 header.classList.add('nav-ready'); 210 toggle.hidden = false; 211 setOpen(false); 212 toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true')); 213 nav.addEventListener('click', event => { 214 const link = event.target.closest('a[href]'); 215 if (!link || !mobile.matches) return; 216 setOpen(false); 217 const href = link.getAttribute('href'); 218 const target = href.startsWith('#') && document.getElementById(href.slice(1)); 219 if (target) { 220 const hadTabindex = target.hasAttribute('tabindex'); 221 if (!hadTabindex) target.setAttribute('tabindex', '-1'); 222 target.focus({ preventScroll: true }); 223 if (!hadTabindex) target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true }); 224 } 225 // Native anchor navigation runs after the menu collapses, preserving flush alignment. 226 }); 227 header.addEventListener('keydown', event => { 228 if (event.key === 'Escape' && mobile.matches && toggle.getAttribute('aria-expanded') === 'true') { 229 setOpen(false); 230 toggle.focus(); 231 } 232 }); 233 document.addEventListener('click', event => { 234 if (mobile.matches && !header.contains(event.target)) setOpen(false); 235 }); 236 mobile.addEventListener('change', () => { 237 const focused = document.activeElement; 238 setOpen(false); 239 if (mobile.matches && nav.contains(focused)) toggle.focus(); 240 else if (!mobile.matches && focused === toggle) nav.querySelector('a').focus(); 241 }); 242})(); 243 244; 245(() => { 246 const root = document.documentElement; 247 const targets = [...document.querySelectorAll('[data-reveal]')]; 248 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 249 const active = new Map(); 250 const started = new WeakSet(); 251 const ease = 'cubic-bezier(.22,1,.36,1)'; 252 let enabled = root.classList.contains('motion-ready'); 253 let observer; 254 function show(element) { 255 element.setAttribute('data-revealed', ''); 256 const animation = active.get(element); 257 active.delete(element); 258 animation?.cancel(); 259 observer?.unobserve(element); 260 } 261 function stop() { 262 enabled = false;
263 root.classList.remove('motion-ready'); 264 targets.forEach(show); 265 observer?.disconnect(); 266 clearTimeout(window.northlitMotionFallback); 267 } 268 if (!enabled || reduced.matches || document.hidden || 269 typeof Element.prototype.animate !== 'function' || !('IntersectionObserver' in window)) { 270 stop(); return; 271 } 272 function enter(element, delay = 0, distance = 16, duration = 720) { 273 if (started.has(element)) return; 274 started.add(element); 275 if (!enabled || reduced.matches || document.hidden || element.contains(document.activeElement)) { 276 show(element); return; 277 } 278 try { 279 // CSS already hides this element before first paint. Both-fill also keeps 280 // the hidden first frame in effect throughout the stagger delay. 281 // 282 // data-reveal="mask": the line rises out of a clipping parent and never 283 // fades â the clip IS the reveal, which is what reads as typeset rather 284 // than faded-in. It travels 120% of its own height so it clears the 285 // mask's descender padding at any size. Everything else is the house 286 // rise-and-fade. 287 // "unveil": a frame wipes open from the top. Imagery gets this instead of 288 // a rise so a photograph arrives as a picture being uncovered rather than 289 // a box sliding in. Slower than a rise â a big frame opening fast reads 290 // cheap. `show()` cancels the fill when it lands, so the wipe never 291 // freezes a transform onto an image that also has a hover zoom. 292 const variant = element.dataset.reveal; 293 const frames = variant === 'mask' 294 ? [{ transform: 'translate3d(0,120%,0)' }, { transform: 'translate3d(0,0,0)' }] 295 : variant === 'unveil' 296 ? [{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }] 297 : [ 298 { opacity: 0, transform: `translate3d(0,${distance}px,0)` }, 299 { opacity: 1, transform: 'translate3d(0,0,0)' }, 300 ]; 301 const span = variant === 'unveil' ? 900 : duration; 302 const animation = element.animate(frames, { duration: span, delay, easing: ease, fill: 'both' }); 303 active.set(element, animation); 304 animation.finished.then(() => show(element), () => { 305 if (active.get(element) === animation) show(element); 306 }); 307 } catch { show(element); } 308 } 309 try { 310 // Stagger is derived, not listed: any parent holding two or more reveal 311 // targets cascades them. The previous hardcoded selector list meant every 312 // section added after it shipped with no cascade at all. 313 const delays = new WeakMap(); 314 // An explicit [data-reveal-group] cascades every target inside it in 315 // document order, ACROSS nesting â so a kicker, the individual lines of 316 // its heading and the body copy read as one sequence even though the 317 // lines live inside the <h2>. Parent-grouping alone can't express that. 318 const grouped = new Set(); 319 document.querySelectorAll('[data-reveal-group]').forEach(group => { 320 [...group.querySelectorAll('[data-reveal]')].forEach((child, i) => { 321 delays.set(child, Math.min(i * 60, 240)); 322 grouped.add(child); 323 }); 324 }); 325 const groups = new Map(); 326 targets.forEach(element => { 327 if (grouped.has(element)) return; 328 const parent = element.parentElement; 329 if (!parent) return; 330 const group = groups.get(parent); 331 if (group) group.push(element); 332 else groups.set(parent, [element]); 333 }); 334 groups.forEach(group => { 335 if (group.length < 2) return; 336 group.forEach((child, i) => delays.set(child, Math.min(i * 60, 180))); 337 }); 338 // A target inside a horizontally scrolling rail can sit outside the rail's 339 // visible slice, and an observer measures against every ancestor's clip â 340 // so it would never intersect and would stay hidden until the reader 341 // scrolled that rail sideways. Watch the RAIL and cascade its children. 342 function railOf(element) { 343 let parent = element.parentElement; 344 while (parent && parent !== document.body) { 345 const style = getComputedStyle(parent); 346 if (/(auto|scroll)/.test(style.overflowX) && parent.scrollWidth > parent.clientWidth + 2) return parent; 347 parent = parent.parentElement; 348 } 349 return null; 350 } 351 // Some targets can't be observed where they stand, because an observer 352 // intersects through every ancestor's overflow clip: 353 // - a masked line starts 120% BELOW its own clip, so it has no visible 354 // area until it reveals â observed directly it never fires at all 355 // (section headings stayed invisible this way; the hero only escaped 356 // because its lines are started directly, not observed); 357 // - a card scrolled out of a horizontal rail has none until the reader 358 // scrolls sideways. 359 // So a target is watched through the nearest container that is always 360 // measurable: its [data-reveal-group] (which also starts the group's 361 // cascade together), else its rail, else â for a mask â the clip it 362 // rises out of. 363 function watchPointOf(element) { 364 const group = element.closest('[data-reveal-group]'); 365 if (group) return railOf(group) || group; 366 const rail = railOf(element); 367 if (rail) return rail; 368 if (element.dataset.reveal === 'mask') return element.parentElement; 369 return null; 370 } 371 const watched = new Map(); 372 observer = new IntersectionObserver(entries => {
373 entries.forEach(entry => { 374 if (!entry.isIntersecting) return; 375 observer.unobserve(entry.target); 376 const members = watched.get(entry.target); 377 if (members) members.forEach(child => enter(child, delays.get(child) || 0)); 378 else enter(entry.target, delays.get(entry.target) || 0); 379 }); 380 }, { threshold: 0, rootMargin: '0px 0px 48px 0px' }); 381 const hero = document.querySelector('.photo-hero'); 382 const heroTargets = new Set(hero?.querySelectorAll('[data-reveal]') || []); 383 [...heroTargets].forEach((element, i) => enter(element, i * 80, 20, 800)); 384 targets.filter(element => !heroTargets.has(element)).forEach(element => { 385 const point = watchPointOf(element); 386 if (!point) { observer.observe(element); return; } 387 const members = watched.get(point); 388 if (members) members.push(element); 389 else { watched.set(point, [element]); observer.observe(point); } 390 }); 391 // Keyboard focus, anchor jumps, reduced motion and background tabs never 392 // leave content hidden or replay an entrance under the user's focus. 393 document.addEventListener('focusin', event => { 394 const element = event.target.closest?.('[data-reveal]'); 395 if (element) { started.add(element); show(element); } 396 }); 397 window.addEventListener('hashchange', stop); 398 window.addEventListener('pageshow', event => { if (event.persisted) stop(); }); 399 reduced.addEventListener('change', () => { if (reduced.matches) stop(); }); 400 document.addEventListener('visibilitychange', () => { if (document.hidden) stop(); }); 401 clearTimeout(window.northlitMotionFallback); 402 } catch { stop(); } 403})(); 404 405;
406// Native overflow supports touch, trackpads, keyboard focus and explicit paging. 407(()=>{ 408 const reduced=matchMedia('(prefers-reduced-motion: reduce)'); 409 document.querySelectorAll('[data-gallery]').forEach(shell=>{ 410 const rail=shell.querySelector('.horizontal-rail'); 411 const controls=[...document.querySelectorAll('[data-rail]')].filter(b=>b.dataset.rail===rail.id); 412 function update(){const end=rail.scrollWidth-rail.clientWidth;const start=rail.scrollLeft<=2,finished=rail.scrollLeft>=end-2;shell.classList.toggle('is-end',finished);controls.forEach(b=>{b.disabled=b.dataset.step==='-1'?start:finished})} 413 function move(step){const card=rail.firstElementChild;const distance=(card?.getBoundingClientRect().width||rail.clientWidth)+parseFloat(getComputedStyle(rail).gap||0);rail.scrollBy({left:distance*step,behavior:reduced.matches?'instant':'smooth'})} 414 controls.forEach(b=>b.addEventListener('click',()=>move(Number(b.dataset.step)))); 415 rail.addEventListener('keydown',e=>{if(e.target!==rail)return;if(e.key==='ArrowRight'||e.key==='ArrowLeft'){e.preventDefault();move(e.key==='ArrowRight'?1:-1)}}); 416 rail.addEventListener('scroll',update,{passive:true}); 417 if('ResizeObserver' in window)new ResizeObserver(update).observe(rail);else window.addEventListener('resize',update); 418 update(); 419 }); 420})(); 421 422; 423/* Sample the rendered image crop under each text block, not the whole photograph. */ 424(() => { 425 const ink = [0, 0, 0], paper = [250, 249, 246]; 426 const linear = value => { value /= 255; return value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4; }; 427 const luminance = rgb => .2126 * linear(rgb[0]) + .7152 * linear(rgb[1]) + .0722 * linear(rgb[2]); 428 const ratio = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05); 429 function analyze(pixels) { 430 if (!pixels.length) return { tone: 'light' }; 431 // Choose text ink from the actual photograph without adding a backdrop. 432 // Median luminance resists small highlights and shadows in detailed images. 433 const values = pixels.map(luminance).sort((a, b) => a - b); 434 const median = values[Math.floor(values.length / 2)]; 435 return { tone: ratio(median, luminance(ink)) >= ratio(median, luminance(paper)) ? 'dark' : 'light' }; 436 } 437 const hero = document.querySelector('.magazine-hero'); 438 window.NorthlitHeroContrast = { analyze }; 439 if (!hero) return; 440 const blocks = [...hero.querySelectorAll('.hero-title, .hero-copy')]; 441 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 442 const running = new WeakMap(); 443 let activeImage, revision = 0, resizeFrame; 444 function position(value, free, axis) { 445 const keywords = { left: 0, top: 0, center: .5, right: 1, bottom: 1 }; 446 if (value in keywords) return free * keywords[value]; 447 if (value?.endsWith('%')) return free * parseFloat(value) / 100; 448 return Number.isFinite(parseFloat(value)) ? parseFloat(value) : free * .5; 449 } 450 function imageCanvas(image) { 451 const bounds = image.getBoundingClientRect(), style = getComputedStyle(image); 452 if (!bounds.width || !bounds.height || !image.naturalWidth) throw new Error('Image not laid out'); 453 const canvas = document.createElement('canvas'); 454 canvas.width = 192; canvas.height = Math.max(1, Math.round(192 * bounds.height / bounds.width)); 455 const ctx = canvas.getContext('2d', { willReadFrequently: true }); 456 if (!ctx) throw new Error('Canvas unavailable'); 457 const scale = canvas.width / bounds.width; 458 ctx.fillStyle = style.backgroundColor === 'rgba(0, 0, 0, 0)' ? '#111113' : style.backgroundColor; 459 ctx.fillRect(0, 0, canvas.width, canvas.height); 460 const fit = (style.objectFit === 'contain' ? Math.min : Math.max)(bounds.width / image.naturalWidth, bounds.height / image.naturalHeight); 461 const width = image.naturalWidth * fit, height = image.naturalHeight * fit; 462 const [x = '50%', y = '50%'] = style.objectPosition.split(' '); 463 ctx.filter = style.filter || 'none'; 464 ctx.drawImage(image, position(x, bounds.width - width) * scale, position(y, bounds.height - height) * scale, width * scale, height * scale); 465 return { bounds, scale, ctx, canvas }; 466 } 467 function sample(block, source) { 468 const b = block.getBoundingClientRect(), { bounds, scale, ctx, canvas } = source; 469 const x = Math.max(0, Math.floor((b.left - bounds.left) * scale)); 470 const y = Math.max(0, Math.floor((b.top - bounds.top) * scale)); 471 const w = Math.min(canvas.width - x, Math.ceil(b.width * scale)); 472 const h = Math.min(canvas.height - y, Math.ceil(b.height * scale)); 473 if (w <= 0 || h <= 0) return []; 474 const data = ctx.getImageData(x, y, w, h).data, pixels = []; 475 const stride = Math.max(1, Math.floor(w * h / 900)); 476 for (let i = 0; i < data.length; i += stride * 4) pixels.push([data[i], data[i + 1], data[i + 2]]); 477 return pixels; 478 } 479 async function apply(block, result, animate, request) { 480 running.get(block)?.cancel(); 481 const changed = block.dataset.heroTone && block.dataset.heroTone !== result.tone; 482 let fade; 483 if (changed && animate && !reduced.matches && typeof block.animate === 'function') {
484 // Fade through transparency rather than interpolating white through muddy grey. 485 fade = block.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 140, fill: 'forwards', easing: 'ease-out' }); 486 running.set(block, fade); 487 try { await fade.finished; } catch { return; } 488 } 489 if (request !== revision) { fade?.cancel(); return; } 490 block.dataset.heroTone = result.tone; 491 fade?.cancel(); 492 if (changed && animate && !reduced.matches && typeof block.animate === 'function') { 493 const reveal = block.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 520, easing: 'cubic-bezier(.22,1,.36,1)' }); 494 running.set(block, reveal); 495 reveal.finished.then(() => { if (running.get(block) === reveal) running.delete(block); }, () => {}); 496 } 497 } 498 async function update(image, animate = true) { 499 if (!image) return; 500 activeImage = image; 501 const request = ++revision; 502 try { await image.decode(); } catch { return; } 503 if (request !== revision) return; 504 let source; 505 try { source = imageCanvas(image); } catch {} 506 for (const block of blocks) { 507 let result; 508 try { if (!source) throw new Error('No image sample'); result = analyze(sample(block, source)); } 509 catch { result = { tone: image.dataset.heroFallbackTone || 'light' }; } 510 apply(block, result, animate, request); 511 } 512 } 513 function refresh() { cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => update(activeImage, false)); } 514 window.NorthlitHeroContrast.update = update; 515 if ('ResizeObserver' in window) { 516 const observer = new ResizeObserver(refresh); observer.observe(hero); blocks.forEach(block => observer.observe(block)); 517 } else window.addEventListener('resize', refresh); 518 document.fonts?.ready.then(refresh); 519 reduced.addEventListener('change', () => { blocks.forEach(b => running.get(b)?.cancel()); refresh(); }); 520 update(hero.querySelector('.hero-slide.is-active'), false); 521})(); 522 523; 524(() => { 525 const pricing = document.querySelector('.product-pricing'); 526 if (!pricing) return; 527 const buttons = [...pricing.querySelectorAll('[data-billing]')]; 528 const money = value => '$' + value.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(value) ? 0 : 2, maximumFractionDigits: 2 }); 529 buttons.forEach(button => button.addEventListener('click', () => { 530 const annual = button.dataset.billing === 'annual'; 531 buttons.forEach(other => other.setAttribute('aria-pressed', String(other === button))); 532 pricing.querySelectorAll('[data-monthly-price]').forEach(price => { 533 const monthly = Number(price.dataset.monthlyPrice); 534 price.textContent = money(annual ? Math.round(monthly * 10 / 12) : monthly); 535 }); 536 pricing.querySelectorAll('[data-billing-caption]').forEach(caption => { 537 caption.textContent = annual ? money(Number(caption.dataset.billingCaption) * 10) + ' billed annually' : 'Billed monthly'; 538 }); 539 pricing.querySelector('#billing-status').textContent = annual ? 'Annual billing selected: 2 months free. Prices shown are monthly equivalents; credits still refresh monthly. Enterprise uses custom contract terms.' : 'Monthly billing selected. Credits refresh monthly.'; 540 })); 541})(); 542 543; 544(() => { 545 const dialog=document.querySelector('#format-lightbox'); 546 const cards=[...document.querySelectorAll('[data-format]')]; 547 if(!dialog||!cards.length)return; 548 const image=dialog.querySelector('.format-viewer-image'); 549 const title=dialog.querySelector('#format-title'),description=dialog.querySelector('#format-description'); 550 const counter=dialog.querySelector('.format-counter'),status=dialog.querySelector('.format-loading'); 551 const thumbnails=dialog.querySelector('.format-thumbnails'); 552 let current=0,opener,revision=0,previousOverflow=''; 553 const buttons=cards.map((card,index)=>{ 554 const button=document.createElement('button');button.type='button';button.setAttribute('aria-label','Show '+card.dataset.title.toLowerCase()); 555 const thumb=card.querySelector('img').cloneNode();thumb.alt='';thumb.loading='lazy';button.append(thumb); 556 button.addEventListener('click',()=>select(index));thumbnails.append(button);return button; 557 }); 558 async function select(index){ 559 current=(index+cards.length)%cards.length;const request=++revision,card=cards[current],source=card.querySelector('img'); 560 title.textContent=card.dataset.title;description.textContent=card.dataset.description;counter.textContent=String(current+1).padStart(2,'0')+' / '+String(cards.length).padStart(2,'0'); 561 buttons.forEach((b,i)=>b.setAttribute('aria-pressed',String(i===current))); 562 const selected=buttons[current];thumbnails.scrollTo({left:Math.max(0,selected.offsetLeft-thumbnails.offsetLeft-thumbnails.clientWidth/2+selected.offsetWidth/2),behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'}); 563 image.classList.add('is-loading');status.textContent='Loading imageâ¦'; 564 const next=new Image();next.src=source.currentSrc||source.src; 565 try{await next.decode();if(request!==revision||!dialog.open)return;image.src=next.src;image.alt=source.alt;status.textContent='';image.classList.remove('is-loading');} 566 catch{if(request===revision)status.textContent='This image could not load. Choose another image or try again.';} 567 } 568 cards.forEach((card,index)=>card.addEventListener('click',()=>{ 569 opener=card;previousOverflow=document.documentElement.style.overflow;document.documentElement.style.overflow='hidden';dialog.showModal();select(index); 570 })); 571 dialog.querySelector('.format-close').addEventListener('click',()=>dialog.close()); 572 dialog.querySelector('.format-prev').addEventListener('click',()=>select(current-1)); 573 dialog.querySelector('.format-next').addEventListener('click',()=>select(current+1)); 574 dialog.addEventListener('keydown',event=>{if(event.key==='ArrowRight'||event.key==='ArrowLeft'){event.preventDefault();select(current+(event.key==='ArrowRight'?1:-1));}}); 575 dialog.addEventListener('close',()=>{++revision;document.documentElement.style.overflow=previousOverflow;opener?.focus({preventScroll:true});}); 576 // The chosen design is carried directly into the working-prototype lau
576ncher. 577 const directions=[...document.querySelectorAll('[data-workflow-direction]')]; 578 const launch=document.querySelector('.workflow-prototype'); 579 directions.forEach(button=>button.addEventListener('click',()=>{ 580 directions.forEach(b=>b.setAttribute('aria-pressed',String(b===button))); 581 const key=button.dataset.workflowDirection;launch.dataset.openPrototype=key; 582 launch.querySelector('.workflow-prototype-preview').replaceChildren(button.querySelector('.mini').cloneNode(true)); 583 launch.querySelector('.workflow-open').firstChild.textContent='Try the '+key[0].toUpperCase()+key.slice(1)+' prototype '; 584 })); 585})(); 586 587; 588(() => { 589 const hero = document.querySelector('.magazine-hero'); 590 if (!hero) return; 591 const slides = [...hero.querySelectorAll('[data-hero-slide]')]; 592 const controls = hero.querySelector('.hero-rotation-controls'); 593 if (slides.length < 2 || !controls) return; 594 const choices = [...controls.querySelectorAll('[data-hero-frame-choice]')]; 595 const playback = controls.querySelector('.hero-playback'); 596 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 597 let index = 0, pending = 0, timer, visible = true, playing = !reduced.matches; 598 controls.hidden = false; 599 async function select(next) { 600 const request = ++pending; 601 const image = slides[next]; 602 if (!image) return; 603 try { await image.decode(); } catch { return; } 604 if (request !== pending) return; 605 index = next; 606 window.NorthlitHeroContrast?.update(image); 607 slides.forEach((slide, i) => slide.classList.toggle('is-active', i === index)); 608 choices.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.heroFrameChoice === image.dataset.heroSlide))); 609 } 610 function sync() { 611 clearInterval(timer); 612 playback.hidden = reduced.matches; 613 playback.textContent = playing ? 'Pause' : 'Play'; 614 playback.setAttribute('aria-label', playing ? 'Pause hero slideshow' : 'Play hero slideshow'); 615 if (playing && !reduced.matches && visible && !document.hidden && (!controls.contains(document.activeElement) || document.activeElement === playback)) { 616 timer = setInterval(() => { 617 for (let step = 1; step < slides.length; step++) { 618 const next = (index + step) % slides.length; 619 if (slides[next].complete && slides[next].naturalWidth === 0) continue; 620 select(next); break; 621 } 622 }, 3000); 623 } 624 } 625 choices.forEach(button => button.addEventListener('click', () => { 626 select(slides.findIndex(slide => slide.dataset.heroSlide === button.dataset.heroFrameChoice)); 627 sync(); 628 })); 629 playback.addEventListener('click', () => { playing = !playing; sync(); }); 630 controls.addEventListener('focusin', sync); 631 controls.addEventListener('focusout', () => setTimeout(sync, 0)); 632 document.addEventListener('visibilitychange', sync); 633 reduced.addEventListener('change', () => { if (reduced.matches) playing = false; sync(); }); 634 if ('IntersectionObserver' in window) { 635 new IntersectionObserver(entries => { visible = entries[0].isIntersecting; sync(); }, { threshold: .05 }).observe(hero); 636 } 637 sync(); 638})(); 639 640(()=>{const dialog=document.querySelector('#launch-plan-dialog');if(!dialog)return;const names={pro:'Pro',studio:'Studio',agency:'Agency'},prices={pro:99,studio:299,agency:999},credits={pro:150,studio:450,agency:1500};let opener; 641document.querySelectorAll('[data-launch-plan]').forEach(link=>link.addEventListener('click',event=>{if(typeof dialog.showModal!=='function')return;event.preventDefault();opener=link;const plan=link.dataset.launchPlan;const annual=document.querySelector('[data-billing="annual"]')?.getAttribute('aria-pressed')==='true';const amount=annual?prices[plan]*10:prices[plan];dialog.querySelector('h2').textContent=names[plan];dialog.querySelector('.launch-plan-value').textContent='$'+amount.toLocaleString('en-US')+(annual?' / year':' / month');dialog.querySelector('.launch-plan-period').textContent=annual?'Annual billing · two months free':'Monthly billing';dialog.querySelector('.launch-plan-capacity').textContent=credits[plan].toLocaleString('en-US')+' credits refresh monthly after the trial.';const member=document.documentElement.classList.contains('nl-member');const url=new URL(member?'/home':'/signup',location.origin);if(member)url.searchParams.set('billing','upgrade');else url.searchParams.set('from','landing');
641url.searchParams.set('plan',plan);url.searchParams.set('interval',annual?'annual':'monthly');dialog.querySelector('#launch-plan-continue').href=url.href;dialog.showModal();}));dialog.querySelector('.dialog-close').addEventListener('click',()=>dialog.close());dialog.addEventListener('close',()=>opener?.focus()); 642})(); 643 644; 645// Promo film overlay. Any [data-open-video] opens it; the film plays on open, 646// pauses on close; Esc, the X and a click on the mask all close it. 647(() => { 648 const dialog = document.querySelector('#video-lightbox'); 649 if (!dialog || typeof dialog.showModal !== 'function') return; 650 const video = dialog.querySelector('video'); 651 let opener, previousOverflow = ''; 652 const snapshot = nlTrackVideo(video, 'hero_overlay'); 653 function open(trigger) { 654 opener = trigger; previousOverflow = document.documentElement.style.overflow; 655 document.documentElement.style.overflow = 'hidden'; 656 dialog.showModal(); 657 nlTrack('video_open', { placement: 'hero_overlay', trigger: trigger.classList.contains('hero-watch') ? 'hero_watch' : trigger.id || trigger.className || 'unknown' }); 658 video.play().catch(() => {}); 659 } 660 // Teardown runs synchronously from every close path we own, not only from 661 // the dialog's async `close` event â that event is the fallback for any 662 // closure we didn't initiate. 663 function settle() { 664 // How far they got before closing is the number that matters here. 665 if (!video.paused) nlTrack('video_close', snapshot()); 666 video.pause(); 667 document.documentElement.style.overflow = previousOverflow; 668 opener?.focus({ preventScroll: true }); 669 } 670 function close() { if (dialog.open) dialog.close(); settle(); } 671 document.querySelectorAll('[data-open-video]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); open(button); })); 672 dialog.querySelector('.video-close').addEventListener('click', close); 673 dialog.addEventListener('click', event => { if (event.target === dialog) close(); }); 674 dialog.addEventListener('cancel', event => { event.preventDefault(); close(); }); 675 dialog.addEventListener('close', settle); 676})(); 677 678; 679// Product gallery: screenshots plus the promo film as the last slide. Rotates 680// every 4s while on screen, unhovered, unfocused and not playing; arrows and 681// â/â keys step it and reset the timer. Mirrors the hero slideshow's rules 682// (decode before swap, no autoplay under reduced motion). 683(() => { 684 const gallery = document.querySelector('[data-product-gallery]'); 685 if (!gallery) return; 686 const slides = [...gallery.querySelectorAll('.workflow-gallery-slide')]; 687 const controls = gallery.querySelector('.workflow-gallery-controls');
688 if (slides.length < 2 || !controls) return; 689 const counter = controls.querySelector('.workflow-gallery-counter'); 690 const film = gallery.querySelector('video.workflow-gallery-slide'); 691 const playDisc = gallery.querySelector('[data-gallery-play]'); 692 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 693 const pad = n => String(n).padStart(2, '0'); 694 let index = 0, pending = 0, timer, visible = true, hovered = false, playing = false; 695 controls.hidden = false; 696 // The big play disc shows only over the film slide, and only while the film 697 // isn't running â once it plays, the native controls are enough. 698 function reflectDisc() { if (playDisc && film) playDisc.hidden = !(slides[index] === film && film.paused); } 699 reflectDisc(); 700 async function select(next) { 701 const request = ++pending; 702 const slide = slides[(next + slides.length) % slides.length]; 703 // Decode images before the swap so the crossfade never shows a 704 // half-painted frame â but only when the bytes aren't in yet, and never 705 // for more than 400ms: a background tab can leave decode() pending. 706 if (slide.tagName === 'IMG' && !(slide.complete && slide.naturalWidth)) { 707 try { await Promise.race([slide.decode(), new Promise(resolve => setTimeout(resolve, 400))]); } catch { return; } 708 } 709 if (request !== pending) return; 710 // Stepping away from the film mid-play pauses it: no audio under a screenshot. 711 if (film && slide !== film && !film.paused) film.pause(); 712 index = slides.indexOf(slide); 713 slides.forEach((s, i) => s.classList.toggle('is-active', i === index)); 714 counter.textContent = pad(index + 1) + ' / ' + pad(slides.length); 715 reflectDisc(); 716 } 717 function allowed() { return !playing && !reduced.matches && visible && !hovered && !document.hidden && !gallery.contains(document.activeElement); } 718 function sync() { 719 clearInterval(timer); 720 if (allowed()) timer = setInterval(() => select(index + 1), 4000); 721 } 722 // Reader-driven steps are reported (automatic turns are not). Screenshots 723 // are named by file, not position, so reordering slides never renames them. 724 function report(next, method) { 725 const target = (next + slides.length) % slides.length; 726 const slide = slides[target]; 727 const id = slide === film ? 'film' : (slide.getAttribute('src') || '').split('/').pop().replace(/\.\w+$/, ''); 728 nlTrack('gallery_select', { gallery_name: 'product_screens', item_id: id, item_index: target + 1, method }); 729 } 730 controls.querySelector('[data-gallery-prev]').addEventListener('click', () => { report(index - 1, 'arrow'); select(index - 1); sync(); }); 731 controls.querySelector('[data-gallery-next]').addEventListener('click', () => { report(index + 1, 'arrow'); select(index + 1); sync(); }); 732 gallery.addEventListener('keydown', event => { 733 if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; 734 event.preventDefault(); const next = index + (event.key === 'ArrowRight' ? 1 : -1); report(next, 'key'); select(next); sync(); 735 }); 736 gallery.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') { hovered = true; sync(); } }); 737 gallery.addEventListener('pointerleave', () => { hovered = false; sync(); }); 738 gallery.addEventListener('focusin', sync); 739 gallery.addEventListener('focusout', () => setTimeout(sync, 0)); 740 document.addEventListener('visibilitychange', sync); 741 reduced.addEventListener('change', sync); 742 if (film) { 743 nlTrackVideo(film, 'gallery'); 744 // The reader chose to watch: the rotation stops for as long as the film 745 // plays, and only picks back up once it's paused or done. 746 film.addEventListener('play', () => { playing = true; reflectDisc(); sync(); }); 747 film.addEventListener('pause', () => { playing = false; reflectDisc(); sync(); }); 748 film.addEventListener('ended', () => { playing = false; reflectDisc(); sync(); }); 749 if (playDisc) playDisc.addEventListener('click', () => { film.play().catch(() => {}); }); 750 } 751 if ('IntersectionObserver' in window) { 752 new IntersectionObserver(entries => { visible = entries[0].isIntersecting; sync(); }, { threshold: .05 }).observe(gallery); 753 } 754 sync(); 755})(); 756 757; 758// Sticky header. Past the top it compacts (.is-stuck) and takes the theme that 759// CONTRASTS the section under its bottom edge: light glass over dark sections, 760// dark glass over the ones tagged data-header-tone="light". Scroll events are 761// already frame-paced, and this is a handful of rect reads, so no throttle. 762(() => { 763 const header = document.querySelector('.site-header'); 764 if (!header) return; 765 const lightSections = [...document.querySelectorAll('[data-header-tone="light"]')];
766 function update() { 767 header.classList.toggle('is-stuck', window.scrollY > 8); 768 const edge = header.getBoundingClientRect().bottom; 769 const overLight = lightSections.some(section => { const r = section.getBoundingClientRect(); return r.top <= edge && r.bottom > edge; }); 770 header.dataset.headerTheme = overLight ? 'dark' : 'light'; 771 } 772 addEventListener('scroll', update, { passive: true }); 773 addEventListener('resize', update); 774 update(); 775})();
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.