diff --git a/assets/custom/css/app.css b/assets/custom/css/app.css index 4a992e6..e93dddc 100644 --- a/assets/custom/css/app.css +++ b/assets/custom/css/app.css @@ -3,3 +3,4 @@ .project-video-detail .project-video-detail-copy{position:relative;z-index:2}.project-video-detail .project-video-detail-copy-bottom{margin-top:2.5rem}.project-video-detail .project-video-detail-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-video-detail .project-video-detail-hgroup{flex-flow:row;gap:1.25rem}.project-video-detail .project-video-detail-hgroup.flip{flex-flow:row-reverse}.project-video-detail .project-video-detail-hgroup .main-h.h1{font-size:2.35rem}.project-video-detail .project-video-detail-hgroup .h-txt{max-width:22ch}.project-video-detail .project-video-detail-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-video-detail .project-video-detail-copy{width:86%}.project-video-detail .project-video-detail-hgroup{gap:1rem}.project-video-detail .project-video-detail-hgroup .main-h.h1{font-size:2rem}.project-video-detail .project-video-detail-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .project-composition .project-composition-copy{position:relative;z-index:2}.project-composition .project-composition-copy-bottom{margin-top:2.5rem}.project-composition .project-composition-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-composition .project-composition-hgroup{flex-flow:row;gap:1.25rem}.project-composition .project-composition-hgroup.flip{flex-flow:row-reverse}.project-composition .project-composition-hgroup .main-h.h2{font-size:2.35rem}.project-composition .project-composition-hgroup .h-txt{max-width:22ch}.project-composition .project-composition-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-composition .project-composition-copy{width:86%}.project-composition .project-composition-hgroup{gap:1rem}.project-composition .project-composition-hgroup .main-h.h2{font-size:2rem}.project-composition .project-composition-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .pricing-table-section .pricing-table-shell{padding-top:12svh;padding-bottom:12svh}@media screen and (max-width:767px){.pricing-table-section .pricing-table-shell{padding-top:8svh;padding-bottom:8svh}} +[data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)} diff --git a/assets/custom/css/out.css b/assets/custom/css/out.css index 9639c85..1e8684b 100644 --- a/assets/custom/css/out.css +++ b/assets/custom/css/out.css @@ -3,3 +3,4 @@ .project-video-detail .project-video-detail-copy{position:relative;z-index:2}.project-video-detail .project-video-detail-copy-bottom{margin-top:2.5rem}.project-video-detail .project-video-detail-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-video-detail .project-video-detail-hgroup{flex-flow:row;gap:1.25rem}.project-video-detail .project-video-detail-hgroup.flip{flex-flow:row-reverse}.project-video-detail .project-video-detail-hgroup .main-h.h1{font-size:2.35rem}.project-video-detail .project-video-detail-hgroup .h-txt{max-width:22ch}.project-video-detail .project-video-detail-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-video-detail .project-video-detail-copy{width:86%}.project-video-detail .project-video-detail-hgroup{gap:1rem}.project-video-detail .project-video-detail-hgroup .main-h.h1{font-size:2rem}.project-video-detail .project-video-detail-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .project-composition .project-composition-copy{position:relative;z-index:2}.project-composition .project-composition-copy-bottom{margin-top:2.5rem}.project-composition .project-composition-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-composition .project-composition-hgroup{flex-flow:row;gap:1.25rem}.project-composition .project-composition-hgroup.flip{flex-flow:row-reverse}.project-composition .project-composition-hgroup .main-h.h2{font-size:2.35rem}.project-composition .project-composition-hgroup .h-txt{max-width:22ch}.project-composition .project-composition-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-composition .project-composition-copy{width:86%}.project-composition .project-composition-hgroup{gap:1rem}.project-composition .project-composition-hgroup .main-h.h2{font-size:2rem}.project-composition .project-composition-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .pricing-table-section .pricing-table-shell{padding-top:12svh;padding-bottom:12svh}@media screen and (max-width:767px){.pricing-table-section .pricing-table-shell{padding-top:8svh;padding-bottom:8svh}} +[data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)} diff --git a/assets/custom/js/interaction-overrides.js b/assets/custom/js/interaction-overrides.js index f75baf7..3e945cd 100644 --- a/assets/custom/js/interaction-overrides.js +++ b/assets/custom/js/interaction-overrides.js @@ -12,7 +12,9 @@ const MEDIA_HANDLE_BOUND_FLAG = "__nodedcMediaHandleBound"; const DOCK_LINK_BOUND_FLAG = "__nodedcDockLinkBound"; const ORDERED_SLIDER_BOUND_FLAG = "__nodedcOrderedSliderBound"; + const LAZY_MEDIA_VIDEO_BOUND_FLAG = "__nodedcLazyMediaVideoBound"; const SCROLL_HEIGHT_SYNC_FLAG = "__nodedcScrollHeightSyncBound"; + const NOTIFICATION_STACK_BOUND_FLAG = "__nodedcNotificationStackBound"; const SCROLL_HEIGHT_SYNC_WARMUP_MS = 14000; const SCROLL_HEIGHT_SYNC_INTERVAL_MS = 90; const IMAGE_EXTENSIONS = new Set(["avif", "gif", "jpeg", "jpg", "png", "svg", "webp"]); @@ -384,6 +386,75 @@ document.querySelectorAll('[data-module="dock"] a[data-app]').forEach(bindDockLink); } + function refreshNotificationStack(wrapper) { + const notifications = Array.from(wrapper.querySelectorAll(':scope > [data-module="notification"]')); + const firstOffset = notifications[0]?.offsetTop || 0; + const collapsedHeight = (notifications[0]?.offsetHeight || 0) + Math.min(Math.max(notifications.length - 1, 0), 3) * 5; + + wrapper.dataset.notificationStackReady = "true"; + wrapper.style.setProperty("--notification-count", String(notifications.length)); + wrapper.style.setProperty("--notification-collapsed-height", `${collapsedHeight}px`); + + notifications.forEach((notification, index) => { + const naturalOffset = notification.offsetTop - firstOffset; + const deckOffset = Math.min(index, 3) * 5; + + notification.style.setProperty("--notification-stack-y", `${deckOffset - naturalOffset}px`); + notification.style.setProperty("--notification-z", String(index + 1)); + }); + } + + function scheduleNotificationStackRefresh(wrapper) { + if (wrapper.__nodedcNotificationStackFrame) return; + + wrapper.__nodedcNotificationStackFrame = window.requestAnimationFrame(() => { + wrapper.__nodedcNotificationStackFrame = 0; + refreshNotificationStack(wrapper); + }); + } + + function bindNotificationStack(wrapper) { + if (!wrapper || wrapper[NOTIFICATION_STACK_BOUND_FLAG]) return; + + wrapper[NOTIFICATION_STACK_BOUND_FLAG] = true; + scheduleNotificationStackRefresh(wrapper); + + const mutationObserver = + typeof MutationObserver === "function" + ? new MutationObserver(() => scheduleNotificationStackRefresh(wrapper)) + : null; + + mutationObserver?.observe(wrapper, { childList: true }); + + wrapper.addEventListener("pointerdown", (event) => { + if (event.target?.closest?.("[data-close]")) return; + wrapper.dataset.notificationExpanded = "true"; + }); + + wrapper.addEventListener("mouseleave", () => { + delete wrapper.dataset.notificationExpanded; + wrapper.scrollTop = 0; + scheduleNotificationStackRefresh(wrapper); + }); + + document.addEventListener( + "pointerdown", + (event) => { + if (wrapper.contains(event.target)) return; + delete wrapper.dataset.notificationExpanded; + wrapper.scrollTop = 0; + }, + { capture: true }, + ); + + window.addEventListener("resize", () => scheduleNotificationStackRefresh(wrapper)); + window.setTimeout(() => scheduleNotificationStackRefresh(wrapper), 120); + } + + function bindNotificationStacks() { + document.querySelectorAll('[data-module="notification-w"]').forEach(bindNotificationStack); + } + function mediaExtension(src) { const cleanSrc = String(src || "").split(/[?#]/)[0]; const match = /\.([a-z0-9]+)$/i.exec(cleanSrc); @@ -407,7 +478,7 @@ const video = document.createElement("video"); video.className = "video-el"; - video.dataset.module = "video-handle"; + video.dataset.lazyVideo = ""; video.src = src; video.muted = true; video.loop = true; @@ -458,8 +529,53 @@ document.querySelectorAll("[data-adaptive-video] video").forEach(bindAdaptiveVideo); } + function playLazyVideo(video) { + video.preload = "auto"; + const playPromise = video.play(); + + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch(() => {}); + } + } + + function bindLazyMediaVideo(video) { + if (!video || video[LAZY_MEDIA_VIDEO_BOUND_FLAG]) return; + + video[LAZY_MEDIA_VIDEO_BOUND_FLAG] = true; + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "none"; + video.removeAttribute("autoplay"); + + if (typeof IntersectionObserver !== "function") { + playLazyVideo(video); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + playLazyVideo(video); + } else { + video.pause(); + } + }); + }, + { rootMargin: "80px 0px", threshold: 0.01 }, + ); + + observer.observe(video); + } + + function bindLazyMediaVideos() { + document.querySelectorAll("video[data-lazy-video]").forEach(bindLazyMediaVideo); + } + bindScrollHeightSync(); hydrateMediaHandles(); + bindNotificationStacks(); if (document.readyState === "loading") { document.addEventListener( @@ -470,7 +586,9 @@ bindInteractiveScroll(); bindOrderedSliders(); bindDockLinks(); + bindNotificationStacks(); bindAdaptiveVideos(); + bindLazyMediaVideos(); scheduleScrollHeightSync(true); }, { once: true }, @@ -481,7 +599,9 @@ bindInteractiveScroll(); bindOrderedSliders(); bindDockLinks(); + bindNotificationStacks(); bindAdaptiveVideos(); + bindLazyMediaVideos(); scheduleScrollHeightSync(true); } @@ -491,14 +611,18 @@ bindInteractiveScroll(); bindOrderedSliders(); bindDockLinks(); + bindNotificationStacks(); bindAdaptiveVideos(); + bindLazyMediaVideos(); scheduleScrollHeightSync(true); window.setTimeout(() => { hydrateMediaHandles(); bindInteractiveScroll(); bindOrderedSliders(); bindDockLinks(); + bindNotificationStacks(); bindAdaptiveVideos(); + bindLazyMediaVideos(); scheduleScrollHeightSync(true); }, 500); }); diff --git a/templates/pages/home/blocks/project-composition.html b/templates/pages/home/blocks/project-composition.html index f470ef4..f431915 100644 --- a/templates/pages/home/blocks/project-composition.html +++ b/templates/pages/home/blocks/project-composition.html @@ -16,7 +16,7 @@
- +
diff --git a/templates/pages/home/blocks/project-video-first.html b/templates/pages/home/blocks/project-video-first.html index 41fa85f..89220f6 100644 --- a/templates/pages/home/blocks/project-video-first.html +++ b/templates/pages/home/blocks/project-video-first.html @@ -1 +1 @@ -
{{text:content.windowTitle}}

{{text:content.heading.muted}}{{text:content.heading.main}}

{{html:content.introHtml}}
{{text:content.features.0.eyebrow}}
{{html:content.features.0.bodyHtml}}
{{text:content.features.1.eyebrow}}
{{html:content.features.1.bodyHtml}}
{{text:content.features.2.eyebrow}}
{{html:content.features.2.bodyHtml}}
{{text:content.features.3.eyebrow}}
{{html:content.features.3.bodyHtml}}
+
{{text:content.windowTitle}}

{{text:content.heading.muted}}{{text:content.heading.main}}

{{html:content.introHtml}}
{{text:content.features.0.eyebrow}}
{{html:content.features.0.bodyHtml}}
{{text:content.features.1.eyebrow}}
{{html:content.features.1.bodyHtml}}
{{text:content.features.2.eyebrow}}
{{html:content.features.2.bodyHtml}}
{{text:content.features.3.eyebrow}}
{{html:content.features.3.bodyHtml}}
diff --git a/tools/admin-server.mjs b/tools/admin-server.mjs index 7189492..c6e9486 100644 --- a/tools/admin-server.mjs +++ b/tools/admin-server.mjs @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { createReadStream } from "node:fs"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; @@ -44,6 +45,30 @@ function send(res, status, body, contentType = "text/plain; charset=utf-8") { res.end(body); } +function sendStream(req, res, status, stream, headers) { + res.writeHead(status, { + "cache-control": "no-store", + "accept-ranges": "bytes", + ...headers, + }); + + if (req.method === "HEAD") { + stream.destroy(); + res.end(); + return; + } + + stream.on("error", () => { + if (!res.headersSent) { + send(res, 500, "Failed to read file"); + return; + } + + res.destroy(); + }); + stream.pipe(res); +} + function sendJson(res, status, payload) { send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8"); } @@ -314,7 +339,52 @@ async function serveStatic(req, res) { } const ext = extname(filePath); - send(res, 200, await readFile(filePath), MIME[ext] || "application/octet-stream"); + const contentType = MIME[ext] || "application/octet-stream"; + const range = req.headers.range; + + if (range) { + const match = /^bytes=(\d*)-(\d*)$/.exec(range); + + if (!match) { + res.writeHead(416, { + "cache-control": "no-store", + "content-range": `bytes */${fileStat.size}`, + }); + res.end(); + return; + } + + const start = match[1] ? Number(match[1]) : 0; + const end = match[2] ? Number(match[2]) : fileStat.size - 1; + + if ( + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 0 || + end < start || + start >= fileStat.size + ) { + res.writeHead(416, { + "cache-control": "no-store", + "content-range": `bytes */${fileStat.size}`, + }); + res.end(); + return; + } + + const cappedEnd = Math.min(end, fileStat.size - 1); + sendStream(req, res, 206, createReadStream(filePath, { start, end: cappedEnd }), { + "content-type": contentType, + "content-length": cappedEnd - start + 1, + "content-range": `bytes ${start}-${cappedEnd}/${fileStat.size}`, + }); + return; + } + + sendStream(req, res, 200, createReadStream(filePath), { + "content-type": contentType, + "content-length": fileStat.size, + }); } catch { send(res, 404, "Not found"); }