@port60/template-kit 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/build-behaviors-runtime.mjs — do not edit by hand.
2
- "use strict";var p60Behaviors=(()=>{var x=Object.defineProperty;var P=Object.getOwnPropertyDescriptor;var q=Object.getOwnPropertyNames;var I=Object.prototype.hasOwnProperty;var C=(o,l)=>{for(var e in l)x(o,e,{get:l[e],enumerable:!0})},F=(o,l,e,n)=>{if(l&&typeof l=="object"||typeof l=="function")for(let t of q(l))!I.call(o,t)&&t!==e&&x(o,t,{get:()=>l[t],enumerable:!(n=P(l,t))||n.enumerable});return o};var O=o=>F(x({},"__esModule",{value:!0}),o);var ee={};C(ee,{initBehaviors:()=>Z});var L=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches;function b(o,l){let e=`data-p60-bound-${l}`;return o.hasAttribute(e)?!0:(o.setAttribute(e,""),!1)}var N=.15;function T(o,l){if(o.length===0)return;if(L()||!("IntersectionObserver"in window)){o.forEach(l);return}let e=new IntersectionObserver(n=>{for(let t of n){if(!t.isIntersecting)continue;(t.boundingClientRect.height>=window.innerHeight||t.intersectionRatio>=N-.001)&&(e.unobserve(t.target),l(t.target))}},{rootMargin:"0px 0px -10% 0px",threshold:[0,N]});o.forEach(n=>e.observe(n))}function R(o){let l=Array.from(o.querySelectorAll("[data-p60-reveal-group]")).filter(n=>!b(n,"reveal-group"));for(let n of l)Array.from(n.children).forEach((t,a)=>{t.style.setProperty("--p60-reveal-index",String(a)),t.classList.add("p60-reveal-child")});T(l,n=>n.classList.add("is-revealed"));let e=Array.from(o.querySelectorAll("[data-p60-reveal]")).filter(n=>!b(n,"reveal"));T(e,n=>n.classList.add("is-revealed"))}var B=/\d[\d,   ]*(?:\.\d+)?/;function _(o){let l=Array.from(o.querySelectorAll("[data-p60-count]")).filter(e=>!b(e,"count"));T(l,e=>{e.classList.add("is-revealed");let n=e.textContent??"",t=B.exec(n);if(!t)return;let a=t[0].replace(/[,   ]+$/,""),c=n.slice(0,t.index),r=n.slice(t.index+a.length),i=/[,   ]/.test(a),d=a.includes(".")?a.length-a.indexOf(".")-1:0,s=Number.parseFloat(a.replace(/[,   ]/g,"")),f=Number.parseFloat(e.getAttribute("data-p60-count")??""),g=Number.isFinite(f)?f:s;if(!Number.isFinite(g)||L())return;let u=Math.min(Number.parseInt(e.getAttribute("data-p60-count-duration")??"1200",10)||1200,4e3),p=new Intl.NumberFormat(document.documentElement.lang||void 0,{useGrouping:i,minimumFractionDigits:d,maximumFractionDigits:d}),m=performance.now(),h=v=>{let y=Math.min((v-m)/u,1),H=1-Math.pow(1-y,3);e.textContent=y<1?c+p.format(g*H)+r:n,y<1&&requestAnimationFrame(h)};requestAnimationFrame(h)})}function D(o){let l=Array.from(o.querySelectorAll("[data-p60-progress]")).filter(e=>!b(e,"progress"));if(!L()){for(let e of l){let n=e.style.width||getComputedStyle(e).width;e.style.transition="none",e.style.width="0px",e.offsetWidth,e.style.transition="width 1.1s cubic-bezier(0.22, 1, 0.36, 1)",e.dataset.p60ProgressTarget=n}T(l,e=>{e.classList.add("is-revealed"),e.style.width=e.dataset.p60ProgressTarget??""})}}function V(o){let l=Array.from(o.querySelectorAll("[data-p60-countdown]")).filter(e=>!b(e,"countdown"));for(let e of l){let n=Date.parse(e.getAttribute("data-p60-until")??"");if(!Number.isFinite(n))continue;let t=e.getAttribute("data-p60-countdown")||"dhm",a=t==="dhms",c=()=>{let i=n-Date.now();if(i<=0){e.classList.add("is-elapsed");let m=e.getAttribute("data-p60-elapsed-text");m&&(e.textContent=m),window.clearInterval(r);return}let d=Math.floor(i/1e3),s=Math.floor(d/86400),f=Math.floor(d%86400/3600),g=Math.floor(d%3600/60),u=d%60,p=[];t.includes("d")&&s>0&&p.push(`${s}d`),p.push(`${f}h`,`${g}m`),a&&p.push(`${u}s`),e.textContent=p.join(" ")},r=window.setInterval(c,a?1e3:15e3);c()}}function $(o){let l=Array.from(o.querySelectorAll("[data-p60-accordion]")).filter(e=>!b(e,"accordion"));for(let e of l){let n=e.getAttribute("data-p60-accordion")!=="multi",t=Array.from(e.querySelectorAll("details"));for(let a of t){a.addEventListener("toggle",()=>{if(a.classList.toggle("is-open",a.open),a.open&&n)for(let i of t)i!==a&&i.open&&(i.open=!1)}),a.open&&a.classList.add("is-open");let r=a.querySelector("summary")?.nextElementSibling;r&&!L()&&a.addEventListener("toggle",()=>{if(!a.open)return;let i=r.scrollHeight;r.style.overflow="hidden",r.animate([{height:"0px",opacity:"0"},{height:`${i}px`,opacity:"1"}],{duration:260,easing:"ease-out"}).addEventListener("finish",()=>{r.style.overflow=""})})}}}function Y(o){let l=Array.from(o.querySelectorAll("[data-p60-carousel]")).filter(e=>!b(e,"carousel"));for(let e of l){let n=Array.from(e.querySelectorAll("[data-p60-slide]"));if(n.length<2)continue;let t=Math.max(0,n.findIndex(m=>m.classList.contains("is-active"))),a=!1,c=[],r=document.createElement("nav");r.className="p60-carousel-dots",r.setAttribute("aria-label","Slides"),n.forEach((m,h)=>{let v=document.createElement("button");v.type="button",v.className="p60-carousel-dot",v.setAttribute("aria-label",`Go to slide ${h+1}`),v.addEventListener("click",()=>i(h,!0)),c.push(v),r.appendChild(v)}),e.appendChild(r),e.setAttribute("aria-roledescription","carousel"),e.hasAttribute("tabindex")||e.setAttribute("tabindex","0");let i=(m,h=!1)=>{t=(m+n.length)%n.length,n.forEach((v,y)=>{v.classList.toggle("is-active",y===t),y===t?(v.removeAttribute("aria-hidden"),v.removeAttribute("inert")):(v.setAttribute("aria-hidden","true"),v.setAttribute("inert",""))}),c.forEach((v,y)=>{v.classList.toggle("p60-carousel-dot--active",y===t),v.setAttribute("aria-current",y===t?"true":"false")}),e.style.setProperty("--p60-carousel-index",String(t)),h&&p()};e.querySelectorAll("[data-p60-carousel-prev]").forEach(m=>m.addEventListener("click",()=>i(t-1,!0))),e.querySelectorAll("[data-p60-carousel-next]").forEach(m=>m.addEventListener("click",()=>i(t+1,!0))),e.addEventListener("keydown",m=>{m.key==="ArrowLeft"&&i(t-1,!0),m.key==="ArrowRight"&&i(t+1,!0)});let d=0,s=0;e.addEventListener("pointerdown",m=>{d=m.clientX,s=m.clientY}),e.addEventListener("pointerup",m=>{let h=m.clientX-d,v=m.clientY-s;Math.abs(h)>40&&Math.abs(h)>Math.abs(v)&&i(t+(h<0?1:-1),!0)}),e.addEventListener("mouseenter",()=>{a=!0}),e.addEventListener("mouseleave",()=>{a=!1}),e.addEventListener("focusin",()=>{a=!0}),e.addEventListener("focusout",()=>{a=!1});let f,g=e.getAttribute("data-p60-carousel")==="auto"&&!L(),u=Math.max(Number.parseInt(e.getAttribute("data-p60-interval")??"6000",10)||6e3,3e3),p=()=>{f!==void 0&&window.clearInterval(f),g&&(f=window.setInterval(()=>{!a&&!document.hidden&&i(t+1)},u))};i(t),p()}}function K(o){let l=Array.from(o.querySelectorAll("[data-p60-sticky-header]")).filter(e=>!b(e,"sticky-header"));for(let e of l){let n=e.getAttribute("data-p60-sticky-header")==="auto-hide",t=window.scrollY,a=!1,c=()=>{let r=window.scrollY;e.classList.toggle("is-condensed",r>24),n&&(r>160&&r>t?e.classList.add("is-hidden"):e.classList.remove("is-hidden")),t=r,a=!1};window.addEventListener("scroll",()=>{a||(a=!0,requestAnimationFrame(c))},{passive:!0}),c()}}function X(o){let l=Array.from(o.querySelectorAll("[data-p60-sticky-cta]")).filter(e=>!b(e,"sticky-cta"));for(let e of l){let n=Number.parseInt(e.getAttribute("data-p60-sticky-cta")??"480",10)||480,t=!1,a=()=>{e.classList.toggle("is-stuck",window.scrollY>n),t=!1};window.addEventListener("scroll",()=>{t||(t=!0,requestAnimationFrame(a))},{passive:!0}),a()}}function z(o){let l=Array.from(o.querySelectorAll("[data-p60-lightbox]")).filter(e=>!b(e,"lightbox"));for(let e of l){let n=Array.from(e.querySelectorAll("a[data-p60-lightbox-item]"));if(n.length===0)continue;let t=null,a=0,c=null,r=()=>{t?.remove(),t=null,e.classList.remove("is-open"),document.removeEventListener("keydown",d),c?.focus()},i=f=>{a=(f+n.length)%n.length;let g=n[a];if(!t)return;let u=t.querySelector(".p60-lightbox-img"),p=t.querySelector(".p60-lightbox-caption");u&&(u.src=g.href);let m=g.getAttribute("data-p60-caption")??g.querySelector("img")?.alt??"";p&&(p.textContent=m),u&&(u.alt=m)},d=f=>{if(f.key==="Escape"&&r(),f.key==="ArrowLeft"&&i(a-1),f.key==="ArrowRight"&&i(a+1),f.key==="Tab"&&t){let g=Array.from(t.querySelectorAll("button")),u=g.indexOf(document.activeElement),p=f.shiftKey?u<=0?g.length-1:u-1:u>=g.length-1?0:u+1;g[p]?.focus(),f.preventDefault()}},s=(f,g)=>{c=g,t=document.createElement("div"),t.className="p60-lightbox",t.setAttribute("role","dialog"),t.setAttribute("aria-modal","true"),L()&&(t.style.transition="none"),t.innerHTML='<figure><img class="p60-lightbox-img" alt=""><figcaption class="p60-lightbox-caption"></figcaption></figure>';let u=(m,h,v)=>{let y=document.createElement("button");return y.type="button",y.className=m,y.setAttribute("aria-label",h),y.addEventListener("click",v),t.appendChild(y),y};n.length>1&&(u("p60-lightbox-prev","Previous image",()=>i(a-1)),u("p60-lightbox-next","Next image",()=>i(a+1)));let p=u("p60-lightbox-close","Close",r);t.addEventListener("click",m=>{m.target===t&&r()}),document.body.appendChild(t),e.classList.add("is-open"),document.addEventListener("keydown",d),i(f),p.focus()};n.forEach((f,g)=>f.addEventListener("click",u=>{u.preventDefault(),s(g,f)}))}}function U(o){let l=Array.from(o.querySelectorAll("[data-p60-tabs]")).filter(e=>!b(e,"tabs"));for(let e of l){let n=Array.from(e.querySelectorAll("[data-p60-tab]")),t=Array.from(e.querySelectorAll("[data-p60-panel]"));if(n.length===0||t.length===0)continue;let a=r=>t.find(i=>i.getAttribute("data-p60-panel")===r.getAttribute("data-p60-tab"));n[0].closest("[data-p60-tabs] > *")?.setAttribute("role","tablist");let c=(r,i=!1)=>{for(let d of n){let s=d===r;d.classList.toggle("is-active",s),d.setAttribute("aria-selected",s?"true":"false"),d.setAttribute("tabindex",s?"0":"-1")}for(let d of t){let s=d===a(r);d.classList.toggle("is-active",s),d.hidden=!s}i&&r.focus()};n.forEach((r,i)=>{r.setAttribute("role","tab"),r.addEventListener("click",d=>{d.preventDefault(),c(r)}),r.addEventListener("keydown",d=>{let s=d.key;s==="ArrowLeft"&&c(n[(i-1+n.length)%n.length],!0),s==="ArrowRight"&&c(n[(i+1)%n.length],!0),s==="Home"&&c(n[0],!0),s==="End"&&c(n[n.length-1],!0)})}),t.forEach(r=>r.setAttribute("role","tabpanel")),c(n.find(r=>r.classList.contains("is-active"))??n[0])}}var W=60,j=280,M=()=>window.matchMedia("(hover: hover)").matches,G='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',A=new Set,k=!1;function S(o){o.openTimer!==null&&(window.clearTimeout(o.openTimer),o.openTimer=null),o.closeTimer!==null&&(window.clearTimeout(o.closeTimer),o.closeTimer=null)}function E(o,l=!1){S(o);for(let e of Array.from(A))e!==o&&o.item.contains(e.item)&&E(e);o.item.classList.remove("is-open"),o.toggle.setAttribute("aria-expanded","false"),o.openedBy=null,A.delete(o),l&&o.toggle.focus()}function w(o,l){S(o);for(let e of Array.from(A))e!==o&&e.nav===o.nav&&!e.item.contains(o.item)&&E(e);o.item.classList.add("is-open"),o.toggle.setAttribute("aria-expanded","true"),o.openedBy=l,A.add(o)}function J(o){for(let e of Array.from(o.querySelectorAll("[data-p60-nav]"))){let n=Array.from(e.querySelectorAll("[data-p60-nav-item]")).filter(t=>t.closest("[data-p60-nav]")===e&&!b(t,"nav-item"));for(let t of n){let a=s=>s.closest("[data-p60-nav-item]")===t,c=Array.from(t.querySelectorAll("[data-p60-nav-toggle]")).find(a)??null,r=Array.from(t.querySelectorAll("[data-p60-nav-menu]")).find(a)??null;if(!c||!r)continue;let i={nav:e,item:t,toggle:c,menu:r,openedBy:null,lastPointer:"",openTimer:null,closeTimer:null},d=()=>A.has(i);c.setAttribute("aria-expanded","false"),r.id&&!c.hasAttribute("aria-controls")&&c.setAttribute("aria-controls",r.id),t.addEventListener("mouseenter",()=>{M()&&(i.closeTimer!==null&&(window.clearTimeout(i.closeTimer),i.closeTimer=null),!d()&&i.openTimer===null&&(i.openTimer=window.setTimeout(()=>{i.openTimer=null,w(i,"hover")},W)))}),t.addEventListener("mouseleave",()=>{M()&&(i.openTimer!==null&&(window.clearTimeout(i.openTimer),i.openTimer=null),d()&&i.openedBy==="hover"&&i.closeTimer===null&&(i.closeTimer=window.setTimeout(()=>{i.closeTimer=null,E(i)},j)))}),c.addEventListener("pointerdown",s=>{i.lastPointer=s.pointerType??""}),c.addEventListener("click",s=>{let f=s.detail===0||i.lastPointer==="touch"||i.lastPointer==="pen"||!M();if(!d()){s.preventDefault(),s.stopPropagation(),w(i,"toggle");return}!f&&i.openedBy==="hover"||(s.preventDefault(),s.stopPropagation(),E(i))}),c.addEventListener("keydown",s=>{s.key==="ArrowDown"&&(s.preventDefault(),d()||w(i,"toggle"),r.querySelector(G)?.focus())}),t.addEventListener("keydown",s=>{s.key==="Escape"&&d()&&(s.stopPropagation(),E(i,!0))}),t.addEventListener("focusin",s=>{s.target instanceof Node&&r.contains(s.target)&&!d()&&w(i,"focus")}),t.addEventListener("focusout",s=>{let f=s.relatedTarget;f instanceof Node&&!t.contains(f)&&d()&&E(i)})}if(!b(e,"nav-fit")){let a=u=>{for(let p=1;p<=3;p++)e.classList.toggle(`is-fit-${p}`,p<=u)},c=u=>{let p=u.getBoundingClientRect();return p.width>0||p.height>0?[u]:Array.from(u.children).flatMap(c)},r=u=>{let p=-1/0;for(let m of u.flatMap(c)){let h=m.getBoundingClientRect();if(p!==-1/0&&h.top>=p-2)return!0;p=Math.max(p,h.bottom)}return!1},i=()=>{if(e.scrollWidth>e.clientWidth+1||r(Array.from(e.children)))return!1;let u=e.parentElement;return!(u&&getComputedStyle(u).flexWrap==="wrap"&&r(Array.from(u.children)))},d=0,s=()=>{if(d=performance.now(),e.offsetParent===null||!getComputedStyle(e).flexDirection.startsWith("row")){a(0);return}a(0);let u=0;for(;!i()&&u<3;)a(++u);d=performance.now()},f=!1,g=()=>{f||(f=!0,requestAnimationFrame(()=>{f=!1,s()}))};window.addEventListener("resize",g,{passive:!0}),document.fonts?.ready.then(g).catch(()=>{}),document.fonts?.addEventListener("loadingdone",g),"ResizeObserver"in window&&new ResizeObserver(()=>{performance.now()-d>120&&g()}).observe(e),s()}}let l=Array.from(o.querySelectorAll("[data-p60-nav-burger]")).filter(e=>!b(e,"nav-burger"));for(let e of l){let n=e.getAttribute("aria-controls")||e.getAttribute("data-p60-nav-burger")||"",t=n?document.getElementById(n):null;if(!t)continue;let a=()=>t.classList.contains("is-open"),c=r=>{if(t.classList.toggle("is-open",r),e.classList.toggle("is-open",r),e.setAttribute("aria-expanded",String(r)),!r)for(let i of Array.from(A))t.contains(i.item)&&E(i)};e.setAttribute("aria-expanded","false"),e.addEventListener("click",r=>{r.stopPropagation(),c(!a())}),t.addEventListener("click",r=>{r.target instanceof Element&&r.target.closest("a")&&c(!1)}),document.addEventListener("keydown",r=>{r.key==="Escape"&&a()&&(c(!1),e.focus())}),document.addEventListener("click",r=>{a()&&r.target instanceof Node&&!t.contains(r.target)&&!e.contains(r.target)&&c(!1)}),window.addEventListener("resize",()=>{a()&&e.offsetParent===null&&c(!1)})}k||(k=!0,document.addEventListener("click",e=>{for(let n of Array.from(A))(!(e.target instanceof Node)||!n.item.contains(e.target))&&E(n)}))}var Q=[["reveal",R],["counter",_],["progress",D],["countdown",V],["accordion",$],["carousel",Y],["stickyHeader",K],["stickyCta",X],["lightbox",z],["tabs",U],["nav",J]];function Z(o=document){document.documentElement.classList.add("p60-js");for(let[l,e]of Q)try{e(o)}catch(n){console.warn(`[p60] behaviour "${l}" failed to initialise; the others still run`,n)}}return O(ee);})();
2
+ "use strict";var p60Behaviors=(()=>{var N=Object.defineProperty;var C=Object.getOwnPropertyDescriptor;var B=Object.getOwnPropertyNames;var R=Object.prototype.hasOwnProperty;var F=(i,d)=>{for(var e in d)N(i,e,{get:d[e],enumerable:!0})},O=(i,d,e,n)=>{if(d&&typeof d=="object"||typeof d=="function")for(let t of B(d))!R.call(i,t)&&t!==e&&N(i,t,{get:()=>d[t],enumerable:!(n=C(d,t))||n.enumerable});return i};var _=i=>O(N({},"__esModule",{value:!0}),i);var se={};F(se,{initBehaviors:()=>ie});var M=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches;function v(i,d){let e=`data-p60-bound-${d}`;return i.hasAttribute(e)?!0:(i.setAttribute(e,""),!1)}var q=.15;function H(i,d){if(i.length===0)return;if(M()||!("IntersectionObserver"in window)){i.forEach(d);return}let e=new IntersectionObserver(n=>{for(let t of n){if(!t.isIntersecting)continue;(t.boundingClientRect.height>=window.innerHeight||t.intersectionRatio>=q-.001)&&(e.unobserve(t.target),d(t.target))}},{rootMargin:"0px 0px -10% 0px",threshold:[0,q]});i.forEach(n=>e.observe(n))}function D(i){let d=Array.from(i.querySelectorAll("[data-p60-reveal-group]")).filter(n=>!v(n,"reveal-group"));for(let n of d)Array.from(n.children).forEach((t,r)=>{t.style.setProperty("--p60-reveal-index",String(r)),t.classList.add("p60-reveal-child")});H(d,n=>n.classList.add("is-revealed"));let e=Array.from(i.querySelectorAll("[data-p60-reveal]")).filter(n=>!v(n,"reveal"));H(e,n=>n.classList.add("is-revealed"))}var $=/\d[\d,   ]*(?:\.\d+)?/;function V(i){let d=Array.from(i.querySelectorAll("[data-p60-count]")).filter(e=>!v(e,"count"));H(d,e=>{e.classList.add("is-revealed");let n=e.textContent??"",t=$.exec(n);if(!t)return;let r=t[0].replace(/[,   ]+$/,""),l=n.slice(0,t.index),o=n.slice(t.index+r.length),s=/[,   ]/.test(r),c=r.includes(".")?r.length-r.indexOf(".")-1:0,a=Number.parseFloat(r.replace(/[,   ]/g,"")),u=Number.parseFloat(e.getAttribute("data-p60-count")??""),m=Number.isFinite(u)?u:a;if(!Number.isFinite(m)||M())return;let f=Math.min(Number.parseInt(e.getAttribute("data-p60-count-duration")??"1200",10)||1200,4e3),p=new Intl.NumberFormat(document.documentElement.lang||void 0,{useGrouping:s,minimumFractionDigits:c,maximumFractionDigits:c}),b=performance.now(),E=y=>{let A=Math.min((y-b)/f,1),g=1-Math.pow(1-A,3);e.textContent=A<1?l+p.format(m*g)+o:n,A<1&&requestAnimationFrame(E)};requestAnimationFrame(E)})}function Y(i){let d=Array.from(i.querySelectorAll("[data-p60-progress]")).filter(e=>!v(e,"progress"));if(!M()){for(let e of d){let n=e.style.width||getComputedStyle(e).width;e.style.transition="none",e.style.width="0px",e.offsetWidth,e.style.transition="width 1.1s cubic-bezier(0.22, 1, 0.36, 1)",e.dataset.p60ProgressTarget=n}H(d,e=>{e.classList.add("is-revealed"),e.style.width=e.dataset.p60ProgressTarget??""})}}function z(i){let d=Array.from(i.querySelectorAll("[data-p60-countdown]")).filter(e=>!v(e,"countdown"));for(let e of d){let n=Date.parse(e.getAttribute("data-p60-until")??"");if(!Number.isFinite(n))continue;let t=e.getAttribute("data-p60-countdown")||"dhm",r=t==="dhms",l=()=>{let s=n-Date.now();if(s<=0){e.classList.add("is-elapsed");let b=e.getAttribute("data-p60-elapsed-text");b&&(e.textContent=b),window.clearInterval(o);return}let c=Math.floor(s/1e3),a=Math.floor(c/86400),u=Math.floor(c%86400/3600),m=Math.floor(c%3600/60),f=c%60,p=[];t.includes("d")&&a>0&&p.push(`${a}d`),p.push(`${u}h`,`${m}m`),r&&p.push(`${f}s`),e.textContent=p.join(" ")},o=window.setInterval(l,r?1e3:15e3);l()}}function K(i){let d=Array.from(i.querySelectorAll("[data-p60-accordion]")).filter(e=>!v(e,"accordion"));for(let e of d){let n=e.getAttribute("data-p60-accordion")!=="multi",t=Array.from(e.querySelectorAll("details"));for(let r of t){r.addEventListener("toggle",()=>{if(r.classList.toggle("is-open",r.open),r.open&&n)for(let s of t)s!==r&&s.open&&(s.open=!1)}),r.open&&r.classList.add("is-open");let o=r.querySelector("summary")?.nextElementSibling;o&&!M()&&r.addEventListener("toggle",()=>{if(!r.open)return;let s=o.scrollHeight;o.style.overflow="hidden",o.animate([{height:"0px",opacity:"0"},{height:`${s}px`,opacity:"1"}],{duration:260,easing:"ease-out"}).addEventListener("finish",()=>{o.style.overflow=""})})}}}function U(i){let d=Array.from(i.querySelectorAll("[data-p60-carousel]")).filter(e=>!v(e,"carousel"));for(let e of d){let n=Array.from(e.querySelectorAll("[data-p60-slide]"));if(n.length<2)continue;let t=Math.max(0,n.findIndex(g=>g.classList.contains("is-active"))),r=[],l=document.createElement("nav");l.className="p60-carousel-dots",l.setAttribute("aria-label","Slides"),n.forEach((g,L)=>{let h=document.createElement("button");h.type="button",h.className="p60-carousel-dot",h.setAttribute("aria-label",`Go to slide ${L+1}`),h.addEventListener("click",()=>o(L,!0)),r.push(h),l.appendChild(h)}),e.appendChild(l),e.setAttribute("aria-roledescription","carousel"),e.hasAttribute("tabindex")||e.setAttribute("tabindex","0");let o=(g,L=!1)=>{t=(g+n.length)%n.length,n.forEach((h,x)=>{h.classList.toggle("is-active",x===t),x===t?(h.removeAttribute("aria-hidden"),h.removeAttribute("inert")):(h.setAttribute("aria-hidden","true"),h.setAttribute("inert",""))}),r.forEach((h,x)=>{h.classList.toggle("p60-carousel-dot--active",x===t),h.setAttribute("aria-current",x===t?"true":"false")}),e.style.setProperty("--p60-carousel-index",String(t)),L&&E()};e.querySelectorAll("[data-p60-carousel-prev]").forEach(g=>g.addEventListener("click",()=>o(t-1,!0))),e.querySelectorAll("[data-p60-carousel-next]").forEach(g=>g.addEventListener("click",()=>o(t+1,!0))),e.addEventListener("keydown",g=>{g.key==="ArrowLeft"&&o(t-1,!0),g.key==="ArrowRight"&&o(t+1,!0)});let s=0,c=0;e.addEventListener("pointerdown",g=>{s=g.clientX,c=g.clientY}),e.addEventListener("pointerup",g=>{let L=g.clientX-s,h=g.clientY-c;Math.abs(L)>40&&Math.abs(L)>Math.abs(h)&&o(t+(L<0?1:-1),!0)});let a=!1,u=!1,m,f=e.getAttribute("data-p60-carousel")==="auto"&&!M(),p=Math.max(Number.parseInt(e.getAttribute("data-p60-interval")??"6000",10)||6e3,3e3),b=()=>{m!==void 0&&window.clearInterval(m),m=void 0},E=()=>{b(),!(!f||u||M())&&(m=window.setInterval(()=>{!a&&!document.hidden&&o(t+1)},p))},y=null,A=g=>{u=g,e.classList.toggle("is-paused",u),y&&(y.setAttribute("aria-label",u?"Start slide rotation":"Stop slide rotation"),y.classList.toggle("p60-carousel-pause--stopped",u)),E()};f&&(y=document.createElement("button"),y.type="button",y.className="p60-carousel-pause",y.setAttribute("aria-label","Stop slide rotation"),y.addEventListener("click",()=>A(!u)),l.insertBefore(y,l.firstChild)),e.addEventListener("mouseenter",()=>{a=!0}),e.addEventListener("mouseleave",()=>{a=!1}),e.addEventListener("focusin",g=>{g.target!==y&&m!==void 0&&A(!0)}),window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener?.("change",g=>{g.matches&&A(!0)}),o(t),E()}}function X(i){let d=Array.from(i.querySelectorAll("[data-p60-sticky-header]")).filter(e=>!v(e,"sticky-header"));for(let e of d){let n=e.getAttribute("data-p60-sticky-header")==="auto-hide",t=window.scrollY,r=!1,l=()=>{let o=window.scrollY;e.classList.toggle("is-condensed",o>24),n&&(o>160&&o>t?e.classList.add("is-hidden"):e.classList.remove("is-hidden")),t=o,r=!1};window.addEventListener("scroll",()=>{r||(r=!0,requestAnimationFrame(l))},{passive:!0}),l()}}function W(i){let d=Array.from(i.querySelectorAll("[data-p60-sticky-cta]")).filter(e=>!v(e,"sticky-cta"));for(let e of d){let n=Number.parseInt(e.getAttribute("data-p60-sticky-cta")??"480",10)||480,t=!1,r=()=>{e.classList.toggle("is-stuck",window.scrollY>n),t=!1};window.addEventListener("scroll",()=>{t||(t=!0,requestAnimationFrame(r))},{passive:!0}),r()}}function j(i){let d=Array.from(i.querySelectorAll("[data-p60-lightbox]")).filter(e=>!v(e,"lightbox"));for(let e of d){let n=Array.from(e.querySelectorAll("a[data-p60-lightbox-item]"));if(n.length===0)continue;let t=null,r=0,l=null,o=()=>{t?.remove(),t=null,e.classList.remove("is-open"),document.removeEventListener("keydown",c),l?.focus()},s=u=>{r=(u+n.length)%n.length;let m=n[r];if(!t)return;let f=t.querySelector(".p60-lightbox-img"),p=t.querySelector(".p60-lightbox-caption");f&&(f.src=m.href);let b=m.getAttribute("data-p60-caption")??m.querySelector("img")?.alt??"";p&&(p.textContent=b),f&&(f.alt=b)},c=u=>{if(u.key==="Escape"&&o(),u.key==="ArrowLeft"&&s(r-1),u.key==="ArrowRight"&&s(r+1),u.key==="Tab"&&t){let m=Array.from(t.querySelectorAll("button")),f=m.indexOf(document.activeElement),p=u.shiftKey?f<=0?m.length-1:f-1:f>=m.length-1?0:f+1;m[p]?.focus(),u.preventDefault()}},a=(u,m)=>{l=m,t=document.createElement("div"),t.className="p60-lightbox",t.setAttribute("role","dialog"),t.setAttribute("aria-modal","true"),M()&&(t.style.transition="none"),t.innerHTML='<figure><img class="p60-lightbox-img" alt=""><figcaption class="p60-lightbox-caption"></figcaption></figure>';let f=(b,E,y)=>{let A=document.createElement("button");return A.type="button",A.className=b,A.setAttribute("aria-label",E),A.addEventListener("click",y),t.appendChild(A),A};n.length>1&&(f("p60-lightbox-prev","Previous image",()=>s(r-1)),f("p60-lightbox-next","Next image",()=>s(r+1)));let p=f("p60-lightbox-close","Close",o);t.addEventListener("click",b=>{b.target===t&&o()}),document.body.appendChild(t),e.classList.add("is-open"),document.addEventListener("keydown",c),s(u),p.focus()};n.forEach((u,m)=>u.addEventListener("click",f=>{f.preventDefault(),a(m,u)}))}}function G(i){let d=Array.from(i.querySelectorAll("[data-p60-tabs]")).filter(e=>!v(e,"tabs"));for(let e of d){let n=Array.from(e.querySelectorAll("[data-p60-tab]")),t=Array.from(e.querySelectorAll("[data-p60-panel]"));if(n.length===0||t.length===0)continue;let r=o=>t.find(s=>s.getAttribute("data-p60-panel")===o.getAttribute("data-p60-tab"));n[0].closest("[data-p60-tabs] > *")?.setAttribute("role","tablist");let l=(o,s=!1)=>{for(let c of n){let a=c===o;c.classList.toggle("is-active",a),c.setAttribute("aria-selected",a?"true":"false"),c.setAttribute("tabindex",a?"0":"-1")}for(let c of t){let a=c===r(o);c.classList.toggle("is-active",a),c.hidden=!a}s&&o.focus()};n.forEach((o,s)=>{o.setAttribute("role","tab"),o.addEventListener("click",c=>{c.preventDefault(),l(o)}),o.addEventListener("keydown",c=>{let a=c.key;a==="ArrowLeft"&&l(n[(s-1+n.length)%n.length],!0),a==="ArrowRight"&&l(n[(s+1)%n.length],!0),a==="Home"&&l(n[0],!0),a==="End"&&l(n[n.length-1],!0)})}),t.forEach(o=>o.setAttribute("role","tabpanel")),l(n.find(o=>o.classList.contains("is-active"))??n[0])}}var J=60,Q=280,k=()=>window.matchMedia("(hover: hover)").matches,Z='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',T=new Set,P=!1;function I(i){i.openTimer!==null&&(window.clearTimeout(i.openTimer),i.openTimer=null),i.closeTimer!==null&&(window.clearTimeout(i.closeTimer),i.closeTimer=null)}function w(i,d=!1){I(i);for(let e of Array.from(T))e!==i&&i.item.contains(e.item)&&w(e);i.item.classList.remove("is-open"),i.toggle.setAttribute("aria-expanded","false"),i.openedBy=null,T.delete(i),d&&i.toggle.focus()}function S(i,d){I(i);for(let e of Array.from(T))e!==i&&e.nav===i.nav&&!e.item.contains(i.item)&&w(e);i.item.classList.add("is-open"),i.toggle.setAttribute("aria-expanded","true"),i.openedBy=d,T.add(i)}function ee(i){for(let e of Array.from(i.querySelectorAll("[data-p60-nav]"))){let n=Array.from(e.querySelectorAll("[data-p60-nav-item]")).filter(t=>t.closest("[data-p60-nav]")===e&&!v(t,"nav-item"));for(let t of n){let r=a=>a.closest("[data-p60-nav-item]")===t,l=Array.from(t.querySelectorAll("[data-p60-nav-toggle]")).find(r)??null,o=Array.from(t.querySelectorAll("[data-p60-nav-menu]")).find(r)??null;if(!l||!o)continue;let s={nav:e,item:t,toggle:l,menu:o,openedBy:null,lastPointer:"",openTimer:null,closeTimer:null},c=()=>T.has(s);l.setAttribute("aria-expanded","false"),o.id&&!l.hasAttribute("aria-controls")&&l.setAttribute("aria-controls",o.id),t.addEventListener("mouseenter",()=>{k()&&(s.closeTimer!==null&&(window.clearTimeout(s.closeTimer),s.closeTimer=null),!c()&&s.openTimer===null&&(s.openTimer=window.setTimeout(()=>{s.openTimer=null,S(s,"hover")},J)))}),t.addEventListener("mouseleave",()=>{k()&&(s.openTimer!==null&&(window.clearTimeout(s.openTimer),s.openTimer=null),c()&&s.openedBy==="hover"&&s.closeTimer===null&&(s.closeTimer=window.setTimeout(()=>{s.closeTimer=null,w(s)},Q)))}),l.addEventListener("pointerdown",a=>{s.lastPointer=a.pointerType??""}),l.addEventListener("click",a=>{let u=a.detail===0||s.lastPointer==="touch"||s.lastPointer==="pen"||!k();if(!c()){a.preventDefault(),a.stopPropagation(),S(s,"toggle");return}!u&&s.openedBy==="hover"||(a.preventDefault(),a.stopPropagation(),w(s))}),l.addEventListener("keydown",a=>{a.key==="ArrowDown"&&(a.preventDefault(),c()||S(s,"toggle"),o.querySelector(Z)?.focus())}),t.addEventListener("keydown",a=>{a.key==="Escape"&&c()&&(a.stopPropagation(),w(s,!0))}),t.addEventListener("focusin",a=>{a.target instanceof Node&&o.contains(a.target)&&!c()&&S(s,"focus")}),t.addEventListener("focusout",a=>{let u=a.relatedTarget;u instanceof Node&&!t.contains(u)&&c()&&w(s)})}if(!v(e,"nav-fit")){let r=f=>{for(let p=1;p<=3;p++)e.classList.toggle(`is-fit-${p}`,p<=f)},l=f=>{let p=f.getBoundingClientRect();return p.width>0||p.height>0?[f]:Array.from(f.children).flatMap(l)},o=f=>{let p=-1/0;for(let b of f.flatMap(l)){let E=b.getBoundingClientRect();if(p!==-1/0&&E.top>=p-2)return!0;p=Math.max(p,E.bottom)}return!1},s=()=>{if(e.scrollWidth>e.clientWidth+1||o(Array.from(e.children)))return!1;let f=e.parentElement;return!(f&&getComputedStyle(f).flexWrap==="wrap"&&o(Array.from(f.children)))},c=0,a=()=>{if(c=performance.now(),e.offsetParent===null||!getComputedStyle(e).flexDirection.startsWith("row")){r(0);return}r(0);let f=0;for(;!s()&&f<3;)r(++f);c=performance.now()},u=!1,m=()=>{u||(u=!0,requestAnimationFrame(()=>{u=!1,a()}))};window.addEventListener("resize",m,{passive:!0}),document.fonts?.ready.then(m).catch(()=>{}),document.fonts?.addEventListener("loadingdone",m),"ResizeObserver"in window&&new ResizeObserver(()=>{performance.now()-c>120&&m()}).observe(e),a()}}let d=Array.from(i.querySelectorAll("[data-p60-nav-burger]")).filter(e=>!v(e,"nav-burger"));for(let e of d){let n=e.getAttribute("aria-controls")||e.getAttribute("data-p60-nav-burger")||"",t=n?document.getElementById(n):null;if(!t)continue;let r=()=>t.classList.contains("is-open"),l=o=>{if(t.classList.toggle("is-open",o),e.classList.toggle("is-open",o),e.setAttribute("aria-expanded",String(o)),!o)for(let s of Array.from(T))t.contains(s.item)&&w(s)};e.setAttribute("aria-expanded","false"),e.addEventListener("click",o=>{o.stopPropagation(),l(!r())}),t.addEventListener("click",o=>{o.target instanceof Element&&o.target.closest("a")&&l(!1)}),document.addEventListener("keydown",o=>{o.key==="Escape"&&r()&&(l(!1),e.focus())}),document.addEventListener("click",o=>{r()&&o.target instanceof Node&&!t.contains(o.target)&&!e.contains(o.target)&&l(!1)}),window.addEventListener("resize",()=>{r()&&e.offsetParent===null&&l(!1)})}P||(P=!0,document.addEventListener("click",e=>{for(let n of Array.from(T))(!(e.target instanceof Node)||!n.item.contains(e.target))&&w(n)}))}function te(i){let d=Array.from(i.querySelectorAll("[data-p60-reading-progress]")).filter(e=>!v(e,"reading-progress"));for(let e of d){let n=e.getAttribute("data-p60-reading-target"),t=n?document.getElementById(n):null;if(n&&!t)continue;e.setAttribute("role","progressbar"),e.setAttribute("aria-valuemin","0"),e.setAttribute("aria-valuemax","100"),!e.hasAttribute("aria-label")&&!e.hasAttribute("aria-labelledby")&&e.setAttribute("aria-label","Reading progress");let r=!1,l=()=>{let s=window.innerHeight,c=0,a=Math.max(document.documentElement.scrollHeight-s,0);if(t){let m=t.getBoundingClientRect();c=m.top+window.scrollY,a=Math.max(m.height-s,0)}let u=a===0?window.scrollY>=c?1:0:Math.min(Math.max((window.scrollY-c)/a,0),1);e.style.setProperty("--p60-reading-progress",u.toFixed(4)),e.setAttribute("aria-valuenow",String(Math.round(u*100))),e.classList.toggle("is-started",u>0),e.classList.toggle("is-complete",u>=1),r=!1},o=()=>{r||(r=!0,requestAnimationFrame(l))};window.addEventListener("scroll",o,{passive:!0}),window.addEventListener("resize",o,{passive:!0}),l()}}function ne(i){let d=Array.from(i.querySelectorAll("[data-p60-show-more]")).filter(e=>!v(e,"show-more"));for(let e of d){let n=Math.max(Number.parseInt(e.getAttribute("data-p60-show-more")??"6",10)||6,1),t=Array.from(e.querySelectorAll("[data-p60-show-more-item]")),r=e.querySelector("[data-p60-show-more-toggle]")??(e.id?document.querySelector(`[data-p60-show-more-toggle][aria-controls="${e.id}"]`):null);if(!r)continue;let l=t.slice(n);if(l.length===0){r.hidden=!0;continue}let o=r.textContent??"",s=r.getAttribute("data-p60-show-more-toggle")??"";e.id&&!r.hasAttribute("aria-controls")&&r.setAttribute("aria-controls",e.id);let c=a=>{for(let u of l)u.hidden=!a;e.classList.toggle("is-expanded",a),r.setAttribute("aria-expanded",a?"true":"false"),s&&(r.textContent=a?s:o)};r.addEventListener("click",()=>c(r.getAttribute("aria-expanded")!=="true")),c(e.classList.contains("is-expanded"))}}function oe(i){let d=Array.from(i.querySelectorAll("[data-p60-scrollspy]")).filter(e=>!v(e,"scrollspy"));for(let e of d){let n=Number.parseInt(e.getAttribute("data-p60-scrollspy")??"96",10)||96,t=[];for(let s of Array.from(e.querySelectorAll('a[href^="#"]'))){let c=decodeURIComponent((s.getAttribute("href")??"").slice(1)),a=c?document.getElementById(c):null;a&&t.push({link:s,target:a})}if(t.length===0)continue;let r=!1,l=()=>{let s=null,c=-1/0;for(let{link:a,target:u}of t){let m=u.getBoundingClientRect().top;m<=n&&m>c&&(c=m,s=a)}for(let{link:a}of t){let u=a===s;a.classList.toggle("is-current",u),u?a.setAttribute("aria-current","location"):a.removeAttribute("aria-current")}r=!1},o=()=>{r||(r=!0,requestAnimationFrame(l))};window.addEventListener("scroll",o,{passive:!0}),window.addEventListener("resize",o,{passive:!0}),window.addEventListener("hashchange",o),l()}}var re=[["reveal",D],["counter",V],["progress",Y],["countdown",z],["accordion",K],["carousel",U],["stickyHeader",X],["stickyCta",W],["lightbox",j],["tabs",G],["readingProgress",te],["showMore",ne],["scrollspy",oe],["nav",ee]];function ie(i=document){document.documentElement.classList.add("p60-js");for(let[d,e]of re)try{e(i)}catch(n){console.warn(`[p60] behaviour "${d}" failed to initialise; the others still run`,n)}}return _(se);})();
@@ -0,0 +1,53 @@
1
+ // Font-slot resolution for the DEV preview, the same rules as the engine's fonts.ts: the template
2
+ // declares font knobs (slot, default family, the weights its type system uses); a chosen family
3
+ // becomes a --p60s-<key> variable with an honest fallback stack and ONE stylesheet URL for every
4
+ // chosen family at the slot's weights, clamped to the family's real weights. Served from the
5
+ // catalogue's provider (Bunny Fonts, the privacy-first mirror of the Google catalogue).
6
+ import catalogue from '../contract/v1/fonts.json' with { type: 'json' };
7
+
8
+ const byName = new Map(catalogue.families.map((f) => [f.name, f]));
9
+
10
+ export const FONT_PROVIDER_ORIGIN = new URL(catalogue.provider.cssBase).origin;
11
+
12
+ /** A catalogue-valid family name, else null. */
13
+ export function knownFamily(name) {
14
+ return typeof name === 'string' && byName.has(name) ? name : null;
15
+ }
16
+
17
+ /** The CSS font-family value for a slot: quoted family plus its honest generic fallback. */
18
+ export function fontStackFor(name) {
19
+ const family = byName.get(name);
20
+ return family ? `'${family.name}', ${family.stack}` : `'${name}', sans-serif`;
21
+ }
22
+
23
+ function nearestWeight(target, available) {
24
+ let best = available[0];
25
+ for (const w of available) {
26
+ const d = Math.abs(w - target);
27
+ const bestD = Math.abs(best - target);
28
+ if (d < bestD || (d === bestD && w > best)) best = w;
29
+ }
30
+ return best;
31
+ }
32
+
33
+ /** One css2 URL covering every chosen family at its slot's clamped weights, or null. */
34
+ export function fontCssHref(slots) {
35
+ const weightsByFamily = new Map();
36
+ for (const slot of slots) {
37
+ const family = byName.get(slot.family);
38
+ if (!family) continue;
39
+ const set = weightsByFamily.get(family.name) ?? new Set();
40
+ const asked = slot.weights?.length > 0 ? slot.weights : [400, 700];
41
+ for (const weight of asked) set.add(nearestWeight(weight, family.weights));
42
+ weightsByFamily.set(family.name, set);
43
+ }
44
+ if (weightsByFamily.size === 0) return null;
45
+ const parts = [...weightsByFamily.entries()].map(([name, weights]) =>
46
+ `family=${name.replace(/ /g, '+')}:wght@${[...weights].sort((a, b) => a - b).join(';')}`);
47
+ return `${catalogue.provider.cssBase}?${parts.join('&')}&display=swap`;
48
+ }
49
+
50
+ /** A template's own Google Fonts URL, rewritten to the mirror (same css2 API). */
51
+ export function toProvider(url) {
52
+ return String(url).replace('https://fonts.googleapis.com/', `${FONT_PROVIDER_ORIGIN}/`);
53
+ }
@@ -0,0 +1,37 @@
1
+ // Icon resolution for the preview, the same rule as the engine's icons.ts: content carries a
2
+ // Lucide icon NAME ("heart-handshake"), the platform supplies `iconSvg` on items[] entries, and
3
+ // the template renders it through the dialect's `raw` filter. Unknown names resolve to '' so a
4
+ // template's `{% if item.iconSvg %}` branch falls back to the plain name, exactly as it does live.
5
+ import * as lucide from 'lucide-static';
6
+
7
+ const table = lucide;
8
+ const cache = new Map();
9
+
10
+ /** "heart-handshake" / "heart_handshake" / "HeartHandshake" -> "HeartHandshake" (the lucide-static key). */
11
+ function pascal(name) {
12
+ return name.trim().split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
13
+ }
14
+
15
+ /** Resolve a Lucide icon name to its SVG string, or '' when blank or unknown. */
16
+ export function iconSvg(name) {
17
+ if (!name || typeof name !== 'string') return '';
18
+ const key = name.trim();
19
+ const cached = cache.get(key);
20
+ if (cached !== undefined) return cached;
21
+ const svg = table[pascal(key)];
22
+ const out = typeof svg === 'string' ? svg : '';
23
+ cache.set(key, out);
24
+ return out;
25
+ }
26
+
27
+ /** Add a resolved iconSvg to any items[] entries that carry an icon NAME; unchanged otherwise. */
28
+ export function withResolvedIcons(content) {
29
+ if (!content || typeof content !== 'object' || !Array.isArray(content.items)) return content;
30
+ const items = content.items.map((item) => {
31
+ if (item && typeof item === 'object' && typeof item.icon === 'string' && item.icon) {
32
+ return { ...item, iconSvg: iconSvg(item.icon) };
33
+ }
34
+ return item;
35
+ });
36
+ return { ...content, items };
37
+ }
@@ -4709,3 +4709,26 @@ textarea.p60-form-control {
4709
4709
  border-radius: 10px; margin: 0 0 10px; }
4710
4710
  .impact-map-popup-card .impact-map-card-image { width: calc(100% + 32px); margin: -14px -16px 10px;
4711
4711
  border-radius: calc(var(--radius-sm) - 1px) calc(var(--radius-sm) - 1px) 0 0; }
4712
+
4713
+ /* ---- Public listings (docs/reference/public-listings.md) ----
4714
+ Every list is one page at a time; these are the shared "more" affordances: the SSR pager on
4715
+ the campaigns index, the events filter row's gap, and the volunteer chooser's "Show more" row. */
4716
+ .listing-pager {
4717
+ display: flex;
4718
+ flex-wrap: wrap;
4719
+ align-items: center;
4720
+ justify-content: center;
4721
+ gap: 1rem;
4722
+ margin-top: 1.75rem;
4723
+ }
4724
+ .listing-pager-status {
4725
+ color: var(--muted);
4726
+ font-size: 0.9rem;
4727
+ }
4728
+ .event-filters-gap {
4729
+ flex: 1 1 auto;
4730
+ }
4731
+ .vol-more {
4732
+ justify-content: center;
4733
+ text-align: center;
4734
+ }
@@ -16,7 +16,9 @@ import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json'
16
16
  import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
17
17
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
18
18
  import { resolveFixtureArt } from './fixture-art.mjs';
19
- import { buildSiteFixture, applyPreviewContent } from './site-context.mjs';
19
+ import { buildSiteFixture, applyPreviewContent, pageComposition, PAGE_KEYS } from './site-context.mjs';
20
+ import { withResolvedIcons } from './icons.mjs';
21
+ import { FONT_PROVIDER_ORIGIN, fontCssHref, fontStackFor, knownFamily, toProvider } from './fonts.mjs';
20
22
  import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from './focus.mjs';
21
23
 
22
24
  // Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
@@ -351,6 +353,13 @@ function courseListingSkeleton(fx = STUDIO_FX) {
351
353
  </div></section>`;
352
354
  }
353
355
 
356
+ /** The platform's own About body, for a template that does not declare the about page: the
357
+ * composition's sections listed as skeletons, the way the platform renders them itself live. */
358
+ function aboutSkeleton(fx, composition = []) {
359
+ const rows = composition.map((s) => `<article class="event-card"><div class="event-card-body"><h3>${escapeHtml(s.type)}</h3><p class="event-card-when">rendered by the platform</p></div></article>`).join('');
360
+ return `${surfaceDivider('about')}<section class="section"><div class="container"><h1>About us</h1>${rows}</div></section>`;
361
+ }
362
+
354
363
  // surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
355
364
  const SURFACES = {
356
365
  events: { template: 'events', builtin: eventsListingSkeleton },
@@ -366,14 +375,14 @@ const SURFACES = {
366
375
  };
367
376
 
368
377
  /** The routed dev preview's surface names (plus 'home'). */
369
- export const PREVIEW_SURFACES = ['home', ...Object.keys(SURFACES)];
378
+ export const PREVIEW_SURFACES = ['home', 'about', ...Object.keys(SURFACES)];
370
379
 
371
- function surfaceBar(active, focus = 'donate') {
380
+ function surfaceBar(active, focus = 'donate', looks = [], activeLook = null) {
372
381
  const withFocus = (href) => (focus === 'donate' ? href : `${href}${href.includes('?') ? '&' : '?'}focus=${focus}`);
373
382
  const link = (name, href) =>
374
383
  `<a href="${withFocus(href)}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
375
384
  const links = [
376
- link('home', '/'), link('events', '/events'), link('event', '/events?event=fixture'),
385
+ link('home', '/'), link('about', '/about'), link('events', '/events'), link('event', '/events?event=fixture'),
377
386
  link('services', '/services'), link('service', '/services?service=fixture'),
378
387
  link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
379
388
  link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
@@ -384,11 +393,21 @@ function surfaceBar(active, focus = 'donate') {
384
393
  const focusLink = (value, label) =>
385
394
  `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
386
395
  const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
396
+ // Looks: the author's one-click bundles, switchable here the way a charity switches them in
397
+ // Appearance; ?look= on any URL, and ?p60s-<key>=<value> for a single knob.
398
+ const lookLink = (name, href) =>
399
+ `<a href="${href}"${name === activeLook ? ' style="font-weight:700;text-decoration:underline"' : ''}>${escapeHtml(name)}</a>`;
400
+ const lookLinks = looks.length
401
+ ? [lookLink(null, '/').replace('>null<', '>defaults<'), ...looks.map((l) => lookLink(l.name, `/?look=${encodeURIComponent(l.name)}`))]
402
+ : [];
387
403
  return `<nav class="p60-preview-surfaces" aria-label="Preview surfaces" style="position:sticky;top:0;z-index:99;display:flex;gap:12px;flex-wrap:wrap;padding:8px 14px;font:12px/1.4 system-ui,sans-serif;background:#0b1220;color:#e6e9f2;opacity:.94">
388
404
  <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
389
405
  <span style="flex-basis:100%;height:0"></span>
390
406
  <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Leads with</strong>${focusLinks.join('')}
391
- <span style="opacity:.7">the hero widget becomes the donation widget, the volunteer sign-up, or nothing; the buttons follow</span>
407
+ <span style="opacity:.7">the hero widget becomes the donation widget, the volunteer sign-up, or nothing; the buttons follow</span>${lookLinks.length ? `
408
+ <span style="flex-basis:100%;height:0"></span>
409
+ <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Looks</strong>${lookLinks.join('')}
410
+ <span style="opacity:.7">?look=name, or ?p60s-key=value for one knob</span>` : ''}
392
411
  </nav>`;
393
412
  }
394
413
 
@@ -402,23 +421,37 @@ function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
402
421
  return out;
403
422
  }
404
423
 
405
- /** Knob defaults → the same body attributes / CSS vars TemplateHost stamps, minus network fonts
406
- * (the CSP kills font fetches by design; system fallbacks are fine for a structural preview). */
407
- function knobDefaults(manifest) {
424
+ /**
425
+ * Knob values → the same body attributes / CSS vars TemplateHost stamps, with the same guards: an
426
+ * override (a Look, or ?p60s-<key>=) wins over the default; a select value that is not one of the
427
+ * knob's options falls back to the default; a font must be a catalogue family. Font slots come
428
+ * back too, so the dev preview can load the one stylesheet production would (the studio stays
429
+ * sealed and renders the fallback stacks).
430
+ */
431
+ function knobValues(manifest, overrides = {}) {
408
432
  const attrs = [];
409
433
  const vars = [];
434
+ const fontSlots = [];
410
435
  for (const knob of manifest?.settings?.schema ?? []) {
411
- const value = knob.default;
412
- if (value == null || value === '') continue;
436
+ const raw = overrides[knob.key] ?? knob.default;
437
+ if (raw == null || raw === '') continue;
438
+ const value = String(raw);
413
439
  if (knob.kind === 'color') {
414
- if (/^#[0-9a-fA-F]{3,8}$/.test(String(value))) vars.push(`--p60s-${knob.key}: ${value};`);
440
+ if (/^#[0-9a-fA-F]{3,8}$/.test(value)) vars.push(`--p60s-${knob.key}: ${value};`);
415
441
  } else if (knob.kind === 'font') {
416
- vars.push(`--p60s-${knob.key}: '${String(value).replace(/'/g, '')}', system-ui, sans-serif;`);
442
+ const family = knownFamily(value) ?? knownFamily(knob.default);
443
+ if (family) {
444
+ fontSlots.push({ family, weights: knob.weights ?? [] });
445
+ vars.push(`--p60s-${knob.key}: ${fontStackFor(family)};`);
446
+ }
417
447
  } else {
418
- attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(value))}"`);
448
+ const options = Array.isArray(knob.options) ? knob.options : null;
449
+ const chosen = options && !options.includes(value) ? knob.default : raw;
450
+ if (chosen == null || chosen === '') continue;
451
+ attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(chosen))}"`);
419
452
  }
420
453
  }
421
- return { attrs: attrs.join(' '), vars: vars.join(' ') };
454
+ return { attrs: attrs.join(' '), vars: vars.join(' '), fontSlots };
422
455
  }
423
456
 
424
457
  /**
@@ -446,8 +479,15 @@ export async function renderStudioPreview(files, options = {}) {
446
479
  // The one content tree (content model v1): about composed from this manifest's declared
447
480
  // sections, dev preview-content overlaid when the kit passes it (validated there), imagery
448
481
  // resolved exactly like the rest of the fixtures.
482
+ const surface = options.surface ?? 'home';
483
+ // The section-based pages: home and about compose from supports.pages and the catalogue's page
484
+ // assignment (or the preview-content `pages` block, the admin-authored composition previewed).
485
+ // The tree's `about` is the composition of the page being rendered, as it is live.
486
+ const pageOf = (page) => pageComposition(manifest, page, options.previewContent);
487
+ const treePage = PAGE_KEYS.includes(surface) ? surface : 'home';
488
+ const baseSite = buildSiteFixture(manifest, { page: treePage });
449
489
  const site = applyFocus(resolveFixtureArt(
450
- applyPreviewContent(buildSiteFixture(manifest), options.previewContent ?? null),
490
+ applyPreviewContent({ ...baseSite, content: { ...baseSite.content, about: pageOf(treePage) } }, options.previewContent ?? null),
451
491
  artOptions ?? {}), actions);
452
492
  const brand = site.brand ?? fx.brand;
453
493
  // With a content override, the TREE is the source of truth for every fixture view: the routed
@@ -481,7 +521,6 @@ export async function renderStudioPreview(files, options = {}) {
481
521
  };
482
522
  }
483
523
 
484
- const surface = options.surface ?? 'home';
485
524
  let contentHtml;
486
525
  if (surface !== 'home' && SURFACES[surface]) {
487
526
  // A routed platform surface: the theme's own page template when it ships one, else the
@@ -498,32 +537,54 @@ export async function renderStudioPreview(files, options = {}) {
498
537
  } else {
499
538
  contentHtml = def.builtin(fx);
500
539
  }
540
+ } else if (surface === 'about' && !(manifest?.supports?.pages ?? []).includes('about')) {
541
+ // The template declares no about page: live, the platform's own About body renders inside the
542
+ // template's layout, so the preview shows that as a skeleton rather than the home composition.
543
+ contentHtml = aboutSkeleton(fx, pageOf('about'));
501
544
  } else {
545
+ // Render one page composition: each section's Liquid over its content (the sample, or the
546
+ // preview-content override), icons resolved on items like the engine does, an unsupported
547
+ // type omitted, never an error (the contract).
548
+ const renderComposition = async (composition) => {
549
+ const out = [];
550
+ for (const { type, content } of composition) {
551
+ const entry = catalogueByType.get(type);
552
+ const source = files[`sections/${type}.liquid`];
553
+ if (!entry || source == null) continue;
554
+ const base = artOptions ? resolveFixtureArt(content ?? {}, artOptions) : resolveFixtureArt(content ?? {});
555
+ const context = {
556
+ // The home hero carries the resolved actions exactly as the platform hands them over, so a
557
+ // developer sees the widget AND the button that pairs with it. The catalogue sample's
558
+ // givingStyle is dropped here: in the preview the focus decides the style.
559
+ section: withResolvedIcons(type === 'homeHero' ? withResolvedActions({ ...base, givingStyle: undefined, actionStyle: undefined }, actions) : base),
560
+ brand,
561
+ site,
562
+ ...(fx.sections?.[type] ?? {})
563
+ };
564
+ const rendered = await liquid.parseAndRender(source, context);
565
+ // Island skeletons see the SAME context the section rendered with — that is what lets the
566
+ // hero carousel skeleton hydrate from the section's own photo fixtures.
567
+ out.push(partsToHtml(rendered, '', context, fx));
568
+ }
569
+ return out;
570
+ };
502
571
  const sectionsHtml = [];
503
- for (const type of manifest?.supports?.sections ?? []) {
504
- const entry = catalogueByType.get(type);
505
- const source = files[`sections/${type}.liquid`];
506
- if (!entry || source == null) continue;
507
- const sample = artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {});
508
- const context = {
509
- // The home hero carries the resolved actions exactly as the platform hands them over, so a
510
- // developer sees the widget AND the button that pairs with it. The catalogue sample's
511
- // givingStyle is dropped here: in the preview the focus decides the style.
512
- section: type === 'homeHero' ? withResolvedActions({ ...sample, givingStyle: undefined, actionStyle: undefined }, actions) : sample,
513
- brand,
514
- site,
515
- ...(fx.sections?.[type] ?? {})
516
- };
517
- const rendered = await liquid.parseAndRender(source, context);
518
- // Island skeletons see the SAME context the section rendered with — that is what lets the
519
- // hero carousel skeleton hydrate from the section's own photo fixtures.
520
- sectionsHtml.push(partsToHtml(rendered, '', context, fx));
572
+ if (surface === 'about') {
573
+ sectionsHtml.push(...(await renderComposition(pageOf('about'))));
574
+ } else {
575
+ sectionsHtml.push(...(await renderComposition(pageOf('home'))));
576
+ // The studio's single document (no surface requested) keeps showing everything the template
577
+ // ships: the about composition follows the home one under a divider.
578
+ if (!options.surface && (manifest?.supports?.pages ?? []).includes('about')) {
579
+ const about = await renderComposition(pageOf('about'));
580
+ if (about.length) sectionsHtml.push(`<div class="p60-preview-divider" role="note">page: about</div>`, ...about);
581
+ }
521
582
  }
522
583
 
523
584
  // Declared page templates render too (over their page fixtures) — the loop an author lives in
524
585
  // covers every surface they ship, not just home sections. The routed dev preview ALSO serves
525
586
  // each at its own path; this keeps the studio's single document complete.
526
- for (const page of manifest?.supports?.pageTemplates ?? []) {
587
+ for (const page of surface === 'about' ? [] : (manifest?.supports?.pageTemplates ?? [])) {
527
588
  const source = files[`pages/${page}.liquid`];
528
589
  const fixture = fx.pages?.[page];
529
590
  if (source == null || fixture == null) continue;
@@ -540,7 +601,7 @@ export async function renderStudioPreview(files, options = {}) {
540
601
  const rendered = await liquid.parseAndRender(files['layout.liquid'], {
541
602
  brand,
542
603
  site,
543
- nav: fx.layout.nav,
604
+ nav: options.previewContent?.nav ? site.nav : fx.layout.nav,
544
605
  socials: fx.layout.socials ?? [],
545
606
  worship: manifest?.supports?.worship ? (fx.layout.worship ?? null) : null,
546
607
  locale: fx.layout.locale
@@ -551,7 +612,16 @@ export async function renderStudioPreview(files, options = {}) {
551
612
  }
552
613
 
553
614
  const themeCss = files['assets/theme.css'] ?? '';
554
- const { attrs, vars } = knobDefaults(manifest);
615
+ const { attrs, vars, fontSlots } = knobValues(manifest, options.knobs ?? {});
616
+ // Webfonts: the DEV preview loads the same stylesheets production would (the template's own
617
+ // manifest.fonts through the provider mirror, plus one for the chosen font knobs) so type is
618
+ // judged for real locally; the studio render stays network-dead and shows the fallback stacks.
619
+ const webfonts = options.webfonts
620
+ ? [...(manifest?.fonts ?? []).map(toProvider), ...(fontCssHref(fontSlots) ? [fontCssHref(fontSlots)] : [])]
621
+ : [];
622
+ const fontLinks = webfonts.length
623
+ ? `<link rel="preconnect" href="${FONT_PROVIDER_ORIGIN}" crossorigin>\n${webfonts.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('\n')}`
624
+ : '';
555
625
 
556
626
  // The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
557
627
  // authors see their carousels, reveals and tabs living locally. The document stays network-dead
@@ -573,10 +643,15 @@ export async function renderStudioPreview(files, options = {}) {
573
643
  for (const origin of options.contentImageOrigins ?? []) {
574
644
  imgOrigins.add(origin);
575
645
  }
646
+ // The author's own photographs beside the template (a preview/ folder the dev server serves)
647
+ // are same-origin; the studio never admits them, there is no such folder to serve there.
648
+ if (options.localImages) imgOrigins.add("'self'");
576
649
  const imgSrc = ['data:', ...imgOrigins].join(' ');
650
+ const styleSrc = webfonts.length ? `'unsafe-inline' ${FONT_PROVIDER_ORIGIN}` : "'unsafe-inline'";
651
+ const fontSrc = webfonts.length ? ` font-src ${FONT_PROVIDER_ORIGIN};` : '';
577
652
  const csp = runtime
578
- ? `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc}; script-src 'unsafe-inline';`
579
- : `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc};`;
653
+ ? `default-src 'none'; style-src ${styleSrc}; img-src ${imgSrc};${fontSrc} script-src 'unsafe-inline';`
654
+ : `default-src 'none'; style-src ${styleSrc}; img-src ${imgSrc};${fontSrc}`;
580
655
  const motionApproximation = runtime ? '' : `
581
656
  @media (prefers-reduced-motion: no-preference) {
582
657
  [data-p60-carousel] > [data-p60-slide], .p60-preview-slide { animation: p60-preview-crossfade 8s infinite; }
@@ -591,6 +666,7 @@ export async function renderStudioPreview(files, options = {}) {
591
666
  <!-- Network-dead by design: the template's CSS can style, never fetch. -->
592
667
  <meta http-equiv="Content-Security-Policy" content="${csp}">
593
668
  <meta name="viewport" content="width=device-width, initial-scale=1">
669
+ ${fontLinks}
594
670
  <title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')} — studio preview</title>
595
671
  <style>${vars ? `:root { ${vars} }` : ''}
596
672
  .p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
@@ -607,7 +683,7 @@ export async function renderStudioPreview(files, options = {}) {
607
683
  <style>${themeCss}</style>
608
684
  </head>
609
685
  <body ${attrs}>
610
- ${options.surface ? surfaceBar(surface, focus) : ''}
686
+ ${options.surface ? surfaceBar(surface, focus, manifest?.looks ?? [], options.look ?? null) : ''}
611
687
  ${bodyHtml}
612
688
  ${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
613
689
  </body>