/* taste-skill system.js - the page behaviour every build shares. Load it once, at the end of : Write no second script for any of this. - nav: hover menus are CSS; this adds the phone sheet, the accordion, Escape and aria-expanded, and closes the sheet after a tap on any link in it. - tabs: [role=tablist] with [role=tab][aria-controls] and [role=tabpanel]. aria-controls may name several ids (the tab's text and its screen in a frame); they are shown and hidden together. - text highlight: none.

(older markup) stays plain ink from the first frame; nothing changes colour on load (MO-62). - FAQ: every .faq answer is open (ST-47): a

in .faq is opened and stays open. - opening hours:
marks today's dt and dd .is-today in the venue's time zone (each dt names its days, "Mon to Fri", "Sat and Sun", "Daily", or carries data-days="mon-fri" / "sat,sun"). Without data-tz no row is marked: never the visitor's clock, and an .is-today written in the markup is taken out (it would be wrong six days a week). A [data-open-now] line reads "Open now, until 16:00" or "Closed, opens Tuesday at 7:30" from them, never from hours marked data-assumed (the brief did not give them). - forms: form.form is checked on submit: aria-invalid and the reason under each field to fix, a status line, the focus on the first; sent by fetch only to its data-endpoint, and data-sent shown only once that answers (references/system.md, Forms). - moments: a page with [data-moment] loads assets/moments/moments.js (references/moments.md), which mounts the dealt moments once; data-ready waits for them. - peek: [data-peek] holds rows [data-peek-to="key"] and photographs [data-peek-for="key"] (all but the first hidden); the photograph changes at once (MO-07) to the row under the pointer, the keyboard focus or a tap. A place's or a service's one moment (references/sections.md). - phone action bar: body > nav.actionbar shows (data-bar="shown") once the hero's main button has scrolled off the top, and hides (data-bar="hidden", inert) while that button, or a link or button that does what one of the bar's does (the same address, or the same words), is on the screen; never while the focus is in it (references/system.md, Nav). - visuals (assets/effects/fx.js and assets/ui/ui.js are loaded only when the page has any): * mounts every [data-fx] ground; only a ground inside the hero (.hero) moves, and not when its frame holds an object or a scene ([data-print], [data-scene], assets/objects). Every other one is drawn once when it nears the screen, kept as a plain picture and its WebGL context freed (ui.grounds), so a page holds at most 3 live WebGL contexts (4 while an object moves on screen); a ground a fragment drives (the slider) stays live near the screen. The featured plan's lit border (data-glow="edge-glow", data-glow-params) rests on a quiet hairline and runs one light round its edge on hover or focus; * lights every ground that holds UI (a .panel, .panel__screen or .frame) and the closing band's (.frame--cta > .frame__fx) with the page's lit ground: (system.css --ground-*), on light and dark pages alike; without data-ground the page palette picks one. data-fx-palette on the element wins; --hue on it changes only its accent; * starts every pasted [data-ui] fragment (assets/ui//fragment.html, its names, numbers and logos rewritten to the client's) and loads every [data-ui-load] one (the kit's sample text: a draft only, never shipped). Each holds its finished picture (system.js marks it data-ui-still, so ui.js holds it the moment its own load resolves) unless it, or an element around it, carries data-ui-loop (one per card group) or data-ui-cycles. On a phone (under 640px), on kind other () and on a place nothing loops, voice included, and the hero's ground is drawn once: every fragment holds its finished picture and plays only while the pointer is on its card; * pause buttons: ui.js puts one on every loop when it starts to move (a 32px disc 12px in from the bottom-right corner of its panel or frame); system.js gives the hero's moving ground the same button, shared with a loop in that frame. A box that already holds a .loop-pause (a loop the page runs itself) gets none; * sets when all of it is drawn (shoot.mjs and consistency.mjs wait for it). - page tone: from the palette's base (a dark footer stays dark). - pictures: lazy images are fetched once the page has loaded, so no photograph shows as an empty frame; one that fails to load is outlined in red and counted (). - guards: a [bracketed placeholder], a "(sample)" tag, a line that explains or apologises ("Names and figures on this page are examples") or a printed .example address gets a red dashed outline and ; an effect id in more than two sections sets . */ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; const HERE = new URL('./', import.meta.url); /* kind other (a cafe, a shop, a studio, a service firm) and a place: nothing moves by itself (MO-07) */ const kindOther = () => { const m = document.querySelector('meta[name="site-kind"]'); return document.documentElement.dataset.type === 'place' || (!!m && m.content.trim().toLowerCase() === 'other'); }; function nav() { const nav = document.querySelector('.nav'); if (!nav) return; const toggle = nav.querySelector('.nav__toggle'); const bars = ''; const cross = ''; const setOpen = open => { nav.classList.toggle('is-open', open); document.body.classList.toggle('nav-locked', open); if (toggle) { toggle.setAttribute('aria-expanded', String(open)); toggle.innerHTML = open ? cross : bars; } }; toggle?.addEventListener('click', () => setOpen(!nav.classList.contains('is-open'))); nav.querySelectorAll('.nav__item').forEach(item => { if (!item.querySelector('.nav__menu')) return; let btn = item.querySelector('.nav__expand'); if (!btn) { btn = document.createElement('button'); btn.className = 'nav__expand'; btn.type = 'button'; btn.innerHTML = ''; item.querySelector('.nav__link').after(btn); } const label = item.querySelector('.nav__link')?.textContent.trim() || 'section'; btn.setAttribute('aria-label', `Show ${label} links`); btn.setAttribute('aria-expanded', 'false'); btn.addEventListener('click', () => { const open = !item.classList.contains('is-expanded'); item.classList.toggle('is-expanded', open); btn.setAttribute('aria-expanded', String(open)); }); }); document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (nav.classList.contains('is-open')) { setOpen(false); toggle?.focus(); return; } if (nav.contains(document.activeElement)) document.activeElement.blur(); }); // a tap on any link in the open sheet closes it (a top-level word, a menu link, an action, an // in-page #anchor alike), so the page it leads to is never left under the sheet nav.addEventListener('click', e => { if (nav.classList.contains('is-open') && e.target.closest?.('a[href]')) setOpen(false); }); window.matchMedia('(min-width: 1024px)').addEventListener('change', e => { if (e.matches) setOpen(false); }); } function tabs() { const parts = t => (t.getAttribute('aria-controls') || '').split(/\s+/).filter(Boolean).map(id => document.getElementById(id)).filter(Boolean); document.querySelectorAll('[role="tablist"]').forEach(list => { const tabs = [...list.querySelectorAll('[role="tab"]')]; let first = true; const select = (tab, focus) => { tabs.forEach(t => { const on = t === tab; t.setAttribute('aria-selected', String(on)); t.tabIndex = on ? 0 : -1; parts(t).forEach(el => { const was = el.hidden; el.hidden = !on; // a panel or a screen in a frame fades in on a chosen tab only (opacity only, nothing moves; // the panel shown at load is there on the first frame, MO-62) if (on && was && !first && !reduced()) el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 280, easing: 'ease-out' }); }); }); if (focus) tab.focus(); }; tabs.forEach((t, i) => { t.addEventListener('click', () => select(t)); t.addEventListener('keydown', e => { const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0; if (!d) return; e.preventDefault(); select(tabs[(i + d + tabs.length) % tabs.length], true); }); }); select(tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0]); first = false; }); } /* every FAQ answer open, for good: a
in .faq is opened and its summary no longer closes it */ function faq() { document.querySelectorAll('.faq details').forEach(d => { d.open = true; d.addEventListener('toggle', () => { if (!d.open) d.open = true; }); const s = d.querySelector(':scope > summary'); if (s) s.addEventListener('click', e => e.preventDefault()); }); } /* ------------------------------------------------------------------ opening hours today's row in the venue's time zone, from data-tz on the dl; never the visitor's clock */ const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; function daySet(text) { const t = String(text || '').toLowerCase().replace(/\bweekdays\b/g, 'mon to fri').replace(/\bweekends?\b/g, 'sat and sun'); if (/\b(daily|every day|all week|mon(day)?\s*(?:-|–|to)\s*sun(day)?)\b/.test(t)) return new Set(DAYS); const hits = [...t.matchAll(/\b(sun|mon|tue|wed|thu|fri|sat)[a-z]*\.?/g)]; if (!hits.length) return null; const set = new Set(); for (let i = 0; i < hits.length; i++) { const a = DAYS.indexOf(hits[i][1]); const next = hits[i + 1]; const between = next ? t.slice(hits[i].index + hits[i][0].length, next.index) : ''; if (next && /^\s*(?:-|–|to|till|until|through)\s*$/.test(between)) { const b = DAYS.indexOf(next[1]); for (let d = a; ; d = (d + 1) % 7) { set.add(DAYS[d]); if (d === b) break; } i++; } else set.add(DAYS[a]); } return set; } function hours() { document.querySelectorAll('dl.hours').forEach(dl => { dl.querySelectorAll('.is-today').forEach(el => el.classList.remove('is-today')); const tz = dl.dataset.tz; if (!tz) return; let today; try { today = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: tz }).format(new Date()).slice(0, 3).toLowerCase(); } catch { console.warn(`system.js: hours: unknown time zone ${tz}`); return; } for (const dt of dl.querySelectorAll('dt')) { const days = daySet(dt.dataset.days ? dt.dataset.days.replace(/,/g, ' and ') : dt.textContent); if (!days?.has(today)) continue; dt.classList.add('is-today'); for (let dd = dt.nextElementSibling; dd && dd.tagName === 'DD'; dd = dd.nextElementSibling) dd.classList.add('is-today'); break; } }); openNow(); } /* [data-open-now] (a line in the visit block or the hero's notice) reads "Open now, until 16:00" or "Closed, opens Tuesday at 7:30" from the first dl.hours with data-tz, in the venue's time zone; its own text stays when the hours cannot be read (no data-tz, a row without times) */ const DAY_NAMES = { sun: 'Sunday', mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday' }; function openNow() { const outs = document.querySelectorAll('[data-open-now]'); const dl = document.querySelector('dl.hours[data-tz]'); if (!outs.length || !dl) return; // hours the brief does not give (data-assumed on the dl or a row) never say "Open now": a visitor would walk to a // door that may be shut; the line keeps its own text (empty: nothing shows) if (dl.matches('[data-assumed]') || dl.querySelector('[data-assumed]')) return; let parts; try { parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: dl.dataset.tz }) .formatToParts(new Date()).map(p => [p.type, p.value])); } catch { return; } const today = DAYS.indexOf(parts.weekday.slice(0, 3).toLowerCase()); const nowMin = (+parts.hour % 24) * 60 + +parts.minute; const span = day => { for (const dt of dl.querySelectorAll('dt')) { const days = daySet(dt.dataset.days ? dt.dataset.days.replace(/,/g, ' and ') : dt.textContent); if (!days?.has(DAYS[day])) continue; const dd = dt.nextElementSibling; const m = dd && dd.tagName === 'DD' && dd.textContent.match(/(\d{1,2})(?:[:.](\d{2}))?\s*(?:to|-|–|until)\s*(\d{1,2})(?:[:.](\d{2}))?/); return m ? { open: +m[1] * 60 + (+m[2] || 0), close: +m[3] * 60 + (+m[4] || 0), from: m[1] + (m[2] ? ':' + m[2] : ''), to: m[3] + (m[4] ? ':' + m[4] : ':00') } : { closed: true }; } return null; }; const t = span(today); let text = null; if (t && !t.closed && nowMin >= t.open && nowMin < t.close) text = `Open now, until ${t.to}`; else { for (let k = 0; k < 8 && !text; k++) { const d = (today + k) % 7, s2 = span(d); if (!s2 || s2.closed || (k === 0 && nowMin >= s2.open)) continue; text = `Closed, opens ${k === 0 ? 'today' : k === 1 ? 'tomorrow' : DAY_NAMES[DAYS[d]]} at ${s2.from}`; } } if (text) outs.forEach(o => { o.textContent = text; }); } /* ------------------------------------------------------------------ forms form.form (references/system.md, Forms): checked on submit only, never while typing and never by a button held disabled before. A field that fails gets aria-invalid="true" and its reason under it (its .field__error, named by aria-describedby; without one, data-error on the field, else the browser's own words), the status line ([data-form-status], role="status") says how many to fix, and the focus moves to the first. A fixed field clears as it is typed in. With data-endpoint (the client's form address) the form is sent by fetch: "Sending" shows after 300ms, then data-sent once the address answers 2xx, else data-failed; the button is held only while it sends. Without data-endpoint the browser sends it its own way (a mailto: stand-in opens the mail app) and no success line is ever shown. Paste is never blocked. */ function forms() { document.querySelectorAll('form.form').forEach(form => { form.noValidate = true; const status = form.querySelector('[data-form-status]'); if (status && !status.hasAttribute('role') && !status.hasAttribute('aria-live')) status.setAttribute('role', 'status'); const say = text => { if (status) status.textContent = text || ''; }; const fields = [...form.elements].filter(el => el.matches('input, select, textarea') && !/^(hidden|submit|button|reset|image)$/.test(el.type)); const errOf = el => { const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); let err = ids.map(id => document.getElementById(id)).find(e => e && e.classList.contains('field__error')); if (!err) { err = document.createElement('p'); err.className = 'field__error'; err.hidden = true; err.id = `${el.id || el.name || 'field'}-err-${Math.random().toString(36).slice(2, 7)}`; (el.closest('.field') || el.parentElement).append(err); el.setAttribute('aria-describedby', [...ids, err.id].join(' ')); } // the written reason is kept once, the first time; an empty one stays empty, so a later failure shows the // browser's message of that moment, never the one an earlier failure left in the line if (!('text' in err.dataset)) err.dataset.text = err.textContent.trim(); return err; }; const check = el => { const ok = el.checkValidity(); const err = errOf(el); if (ok) { el.removeAttribute('aria-invalid'); err.hidden = true; return true; } el.setAttribute('aria-invalid', 'true'); err.textContent = err.dataset.text || el.dataset.error || el.validationMessage; err.hidden = false; return false; }; fields.forEach(el => el.addEventListener(el.matches('select') ? 'change' : 'input', () => { if (el.getAttribute('aria-invalid') === 'true') check(el); })); form.addEventListener('submit', async e => { const bad = fields.filter(el => !check(el)); if (bad.length) { e.preventDefault(); say(form.dataset.fix ? form.dataset.fix.replace('{n}', bad.length) : bad.length === 1 ? '1 field to fix.' : `${bad.length} fields to fix.`); bad[0].focus(); return; } say(''); const endpoint = (form.dataset.endpoint || '').trim(); if (!/^https?:\/\//.test(endpoint) && !endpoint.startsWith('/')) return; e.preventDefault(); const button = form.querySelector('[type="submit"]'); const wait = setTimeout(() => say(form.dataset.sending || 'Sending'), 300); if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); } try { const res = await fetch(endpoint, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(String(res.status)); clearTimeout(wait); form.reset(); say(form.dataset.sent || 'Sent.'); } catch { clearTimeout(wait); say(form.dataset.failed || 'Not sent: the connection failed. Your words are still here; try again.'); } finally { if (button) { button.disabled = false; button.removeAttribute('aria-busy'); } } }); }); } /* ------------------------------------------------------------------ peek a list beside one photograph that changes to the row under the pointer, the focus or a tap */ function peek() { document.querySelectorAll('[data-peek]').forEach(root => { const rows = [...root.querySelectorAll('[data-peek-to]')]; const pics = [...root.querySelectorAll('[data-peek-for]')]; if (!rows.length || !pics.length) return; let cur = null; // the swap is instant (MO-07): the row's photograph shows, the others hide, nothing fades const show = row => { const pic = pics.find(p => p.dataset.peekFor === row.dataset.peekTo); if (!pic || row === cur) return; cur = row; // the row on show is marked beside its photograph only (from 1024); stacked, the list stays plain const wide = matchMedia('(min-width: 1024px)').matches; rows.forEach(r => r.classList.toggle('is-on', wide && r === row)); pics.forEach(p => { p.hidden = p !== pic; }); }; // every photograph is fetched now, so a swap never shows an empty frame pics.forEach(p => { if (p.loading === 'lazy') p.loading = 'eager'; }); show(rows.find(r => pics.some(p => !p.hidden && p.dataset.peekFor === r.dataset.peekTo)) || rows[0]); rows.forEach(r => { r.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') show(r); }); r.addEventListener('focusin', () => show(r)); r.addEventListener('click', e => { if (!e.target.closest('a, button, input, select, textarea')) show(r); }); }); }); } /* ------------------------------------------------------------------ phone action bar The bar repeats the page's main actions at a phone's foot, so it waits until the hero's main button has scrolled off the top, and steps away while a link or button on the screen does what one of its own does (the same address, or the same words): one screen never shows one action twice. Hidden it is inert (nothing in it takes the focus or is read), and it never hides while the focus is in it. Without script it is always there, and every section pads for it (system.css, --bar). */ function actionbar() { const bar = document.querySelector('body > nav.actionbar'); if (!bar) return; const set = shown => { const v = shown ? 'shown' : 'hidden'; if (bar.dataset.bar === v) return; bar.dataset.bar = v; bar.inert = !shown; }; if (!('IntersectionObserver' in window)) { set(true); return; } set(false); const words = el => (el.textContent || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().toLowerCase(); const own = [...bar.querySelectorAll('a[href], button')]; const hrefs = new Set(own.filter(a => a.tagName === 'A').map(a => a.href)); const labels = new Set(own.map(words).filter(Boolean)); const twins = [...document.querySelectorAll('a[href], button')].filter(el => !bar.contains(el) && ((el.tagName === 'A' && hrefs.has(el.href)) || labels.has(words(el)))); const hero = document.querySelector('main > :is(.hero, .section--hero)'); const lead = hero ? hero.querySelector('.btn--primary') || hero.querySelector('.btn') || hero : null; let past = !lead; // no hero: only the twins on the screen decide const onScreen = new Set(); const update = () => { const show = past && !onScreen.size; if (!show && bar.contains(document.activeElement)) return; set(show); }; if (lead) new IntersectionObserver(es => { for (const e of es) past = !e.isIntersecting && e.boundingClientRect.top < 0; update(); }).observe(lead); // a twin under the bar's own strip is hidden by it, so it counts once it is above the strip if (twins.length) { const io = new IntersectionObserver(es => { for (const e of es) { if (e.isIntersecting) onScreen.add(e.target); else onScreen.delete(e.target); } update(); }, { rootMargin: '0px 0px -64px 0px' }); twins.forEach(t => io.observe(t)); } bar.addEventListener('focusout', () => setTimeout(update)); update(); } /* ------------------------------------------------------------------ visuals */ let probe; function rgb(value) { value = (value || '').trim(); if (!value) return null; probe = probe || document.createElement('canvas').getContext('2d', { willReadFrequently: true }); probe.canvas.width = probe.canvas.height = 1; probe.clearRect(0, 0, 1, 1); probe.fillStyle = '#000'; probe.fillStyle = value; probe.fillRect(0, 0, 1, 1); const d = probe.getImageData(0, 0, 1, 1).data; return d[3] ? [d[0], d[1], d[2]] : null; } const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); // The page's lit ground (references/cards.md: sand, sky, sage or painting, , picked at // random from the three on a tech page; a page of kind other has none, it is flat). Without data-ground the // page palette picks one, so two sites never share it by default. const GROUNDS = ['sand', 'sky', 'sage']; function groundTokens() { const html = document.documentElement; if (!html.dataset.ground) { const first = document.querySelector('.card [data-fx], .frame [data-fx], .frame[data-fx]'); const root = getComputedStyle(html); const key = `${root.getPropertyValue('--p-bg')}${root.getPropertyValue('--p-accent')}`.replace(/\s/g, ''); let h = 0; for (const c of key) h = (h * 31 + c.charCodeAt(0)) >>> 0; html.dataset.ground = first?.dataset.fx === 'painterly-landscape' ? 'painting' : GROUNDS[h % GROUNDS.length]; } const cs = getComputedStyle(html); const t = k => cs.getPropertyValue(`--ground-${k}`).trim(); return { bg: t('bg'), surface: t('surface'), ink: t('ink'), muted: t('muted'), accent: t('accent') }; } // every ground that holds UI, and the closing band's, takes the lit ground on light and dark pages // alike: clearly lighter than a dark card, a clear step darker than white UI, so page, card, panel and // UI are never four near-black layers. data-fx-palette on the element wins; --hue changes its accent. function lightGrounds() { const g = groundTokens(); document.querySelectorAll('[data-fx]').forEach(el => { if (el.dataset.fxPalette) return; const band = el.matches('.frame--cta > .frame__fx'); if (!band && !el.matches('.panel, .panel__screen, .frame')) return; const hue = rgb(getComputedStyle(el).getPropertyValue('--hue')); el.dataset.fxPalette = JSON.stringify({ ...g, ...(hue ? { accent: hex(hue) } : {}) }); // the box itself takes the lit colour too: an effect that keeps its host's own background (a // frame's dark surface on a dark page) then still draws on the lit ground, and nothing flashes dark if (!band) el.style.backgroundColor = g.bg; }); } // the featured plan's lit border (cards.css .plan.is-featured): it rests on a quiet accent hairline; // on hover or keyboard focus one light runs round its edge (edge-glow, mounted for that lap, then // faded and freed), so nothing moves on its own or because the page scrolled function glowOnHover(fx, els) { for (const el of els) { let busy = false; const lap = async () => { if (busy || reduced()) return; busy = true; try { let params = {}; try { params = JSON.parse(el.dataset.glowParams || '{}'); } catch { /* defaults */ } const c = await fx.mount(el, el.dataset.glow, { palette: fx.readPalette(el), params, seed: 2 }); const r = el.getBoundingClientRect(), per = 2 * (r.width + r.height); await new Promise(res => setTimeout(res, per / Math.max(40, Math.min(c.params?.speed || 400, per / 4)) * 1000)); el.classList.add('is-fading'); await new Promise(res => setTimeout(res, 650)); c.destroy(); } catch (e) { console.warn('system.js: glow', e); } el.classList.remove('is-fading'); busy = false; }; el.addEventListener('pointerenter', lap); el.addEventListener('focusin', lap); fx.load(el.dataset.glow).catch(() => {}); } } async function visuals() { // a lit border written as data-fx="edge-glow" is the hover light (data-glow), never a ground document.querySelectorAll('[data-fx="edge-glow"]').forEach(el => { el.dataset.glow = 'edge-glow'; if (el.dataset.fxParams && !el.dataset.glowParams) el.dataset.glowParams = el.dataset.fxParams; el.removeAttribute('data-fx'); }); const grounds = [...document.querySelectorAll('[data-fx]')]; const glows = [...document.querySelectorAll('[data-glow]')]; const frags = [...document.querySelectorAll('[data-ui-load], [data-ui]')].filter(el => !el.parentElement?.closest('[data-ui-load]')); if (!grounds.length && !frags.length && !glows.length) return; // only the hero's ground moves, and not when its frame holds an object (assets/objects: data-print), // which is then that visual's one motion and its pause button the only one in the corner. On a phone // (under 640px), on kind other and on a place nothing loops: the hero's ground is drawn once like the // rest and every fragment holds its finished picture (as most good sites cut motion there), so no // pause button covers the UI. A held fragment still plays while the pointer is on its card. const phone = window.matchMedia('(max-width: 639px)').matches || kindOther(); const box = el => el.closest('.frame, .panel__screen, .panel') || el; const moving = phone ? [] : grounds.filter(el => el.closest('.hero, .section--hero') && !box(el).querySelector('[data-print], [data-scene]')); const live = el => moving.includes(el); const [fx, ui] = await Promise.all([ grounds.length || glows.length ? import(new URL('effects/fx.js', HERE).href).then(m => m.default) : null, import(new URL('ui/ui.js', HERE).href).then(m => m.default), ]); const name = el => el.dataset.uiLoad || el.dataset.ui || ''; const loops = el => !phone && !!el.closest('[data-ui-loop], [data-ui-cycles]'); // a fragment that does not loop is held on its finished picture as soon as its own load resolves frags.forEach(el => { if (!loops(el)) el.setAttribute('data-ui-still', ''); }); const opts = el => { try { return JSON.parse(el.dataset.uiOpts || '{}'); } catch { return {}; } }; const run = el => (el.dataset.uiLoad ? ui.load(el, el.dataset.uiLoad, opts(el)) : ui.start(el)).catch(e => console.warn(e)); if (fx) lightGrounds(); await Promise.all([ // the hero's ground moves and keeps its context; every other ground is baked to a picture fx && Promise.all(moving.map(el => fx.mountAll(el))), fx && grounds.length && ui.grounds(document, fx, { skip: live }), ...frags.map(run), ]); // the hero's ground moves, so it gets the pause button every loop has (one per frame, shared) if (fx && ui) moving.forEach(el => { const c = fx.get(el); if (c) ui.pausable(el.closest('.panel__screen, .panel, .frame') || el, c); }); if (fx && glows.length) glowOnHover(fx, glows); } /* ------------------------------------------------------------------ page tone from the palette's base, so a dark footer stays dark on a dark page (system.css .footer--dark) instead of turning into the light inverse */ function tone() { const c = rgb(getComputedStyle(document.documentElement).getPropertyValue('--p-bg')); if (!c) return; const lin = v => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; const L = 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); document.documentElement.dataset.tone = L < 0.18 ? 'dark' : 'light'; } /* ------------------------------------------------------------------ pictures always draw A photograph never shows as an empty frame: once the page has loaded, every lazy image is fetched (so one far down the page is there when a visitor or a screenshot reaches it), and one that fails to load is outlined in red and counted (), like a placeholder. */ function pictures() { const html = document.documentElement; let broken = 0; const fail = img => { if (img.dataset.broken != null) return; img.dataset.broken = ''; img.setAttribute('data-placeholder', ''); html.dataset.broken = String(++broken); console.warn(`system.js: a picture did not load: ${img.currentSrc || img.src}`); }; document.querySelectorAll('img').forEach(img => { // an SVG with only a viewBox reports no natural width though it draws: decode() tells if (img.complete && img.getAttribute('src') && !img.naturalWidth) img.decode().catch(() => fail(img)); else img.addEventListener('error', () => fail(img), { once: true }); }); const eager = () => document.querySelectorAll('img[loading="lazy"]').forEach(img => { img.loading = 'eager'; }); const idle = window.requestIdleCallback || (f => setTimeout(f, 200)); if (document.readyState === 'complete') idle(eager); else window.addEventListener('load', () => idle(eager), { once: true }); } /* ------------------------------------------------------------------ guards Faults that shipped before: a [bracketed placeholder] or a "(sample)" tag in visible text, a line that explains or apologises ("Names and figures on this page are examples", "made in code", "photographs follow"), a printed .example address, and one effect in more than two sections. Each is marked so it shows in every screenshot and the checker can read it: [data-placeholder] (a red dashed outline, system.css) and ; . */ const PLACEHOLDER = /\[[^\]\n]{1,60}\]|\((?:sample|placeholder|example)[^)]{0,30}\)|\bsample (?:figure|price|name|quote|number|photo|page)s?\b|\bplaceholder\b|\bnames and figures\b|\b(?:are|is) (?:examples?|illustrative|invented)\b|\bfor illustration\b|\b(?:made|drawn|rendered) in code\b|\bphotographs? (?:follow|to follow|coming)\b|\bto be replaced\b|[\w.+-]+@[\w-]+\.example\b|\bwww\.[\w-]+\.example\b/i; function guards() { const html = document.documentElement; let n = 0; const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: t => (t.parentElement?.closest('script, style, template, noscript') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT), }); for (let t = walk.nextNode(); t; t = walk.nextNode()) { if (PLACEHOLDER.test(t.textContent)) { t.parentElement.setAttribute('data-placeholder', ''); n++; } } document.querySelectorAll('img[alt]').forEach(img => { if (PLACEHOLDER.test(img.alt)) { img.setAttribute('data-placeholder', ''); n++; } }); // the old figures note, whatever its words document.querySelectorAll('.figures-note').forEach(el => { if (!el.hasAttribute('data-placeholder')) { el.setAttribute('data-placeholder', ''); n++; } }); if (n) { html.dataset.placeholders = String(n); console.warn(`system.js: ${n} placeholder, sample tag, apology or .example address left in visible text`); } // an effect id in at most two sections (a card group counts once; the closing band is a section) const seen = new Map(); document.querySelectorAll('[data-fx]').forEach(el => { if (el.closest('.nav')) return; // the menu card's panel is hidden until hover: not counted const sec = el.closest('section, footer') || el; if (!seen.has(el.dataset.fx)) seen.set(el.dataset.fx, new Set()); seen.get(el.dataset.fx).add(sec); }); const repeat = [...seen].filter(([, s]) => s.size > 2).map(([id]) => id); if (repeat.length) { html.dataset.fxRepeat = repeat.join(' '); console.warn(`system.js: effect in more than two sections: ${repeat.join(', ')}`); } } /* ------------------------------------------------------------------ moments The page's one or two dealt moments (assets/moments, references/moments.md): loaded only when the page holds a [data-moment]; data-ready waits for them. mount is idempotent, so nothing mounts twice. */ async function moments() { if (!document.querySelector('[data-moment]')) return; const m = await import(new URL('moments/moments.js', HERE).href); await m.ready; } function init() { try { tone(); } catch (e) { console.warn('system.js: tone', e); } nav(); tabs(); try { faq(); } catch (e) { console.warn('system.js: faq', e); } try { hours(); } catch (e) { console.warn('system.js: hours', e); } try { forms(); } catch (e) { console.warn('system.js: forms', e); } try { peek(); } catch (e) { console.warn('system.js: peek', e); } // a bar that could not be wired stays shown, as without script, and the hero pads for it again try { actionbar(); } catch (e) { console.warn('system.js: actionbar', e); const b = document.querySelector('body > nav.actionbar'); if (b) { b.dataset.bar = 'always'; b.inert = false; } } try { pictures(); } catch (e) { console.warn('system.js: pictures', e); } try { guards(); } catch (e) { console.warn('system.js: guards', e); } Promise.all([ visuals().catch(e => console.warn('system.js: visuals', e)), moments().catch(e => console.warn('system.js: moments', e)), ]).finally(() => { document.documentElement.dataset.ready = '1'; }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true }); else init(); export default { init };