PageSourceSearch

https://northlit.ai/landing/page-script.js

js northlit.ai collected 2026-10-05 14:37:35 UTC 46,907 bytes, 775 lines download raw bytes

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.