mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-03 12:27:55 +08:00
* perf(landing): load matter-js + cobe on demand, not inlined The homepage inlined the full matter-js physics engine (~83KB) and the cobe globe runtime (~13KB) into every document, even though both are below-the-fold decorations. That shipped ~96KB of decorative JavaScript in the critical HTML to every visitor — phones and reduced-motion readers included — inflating the document the browser must download and parse before the page settles. Vendor both runtimes to public/enhancers/*.js (regenerated from node_modules on every build/dev, so they always match the installed versions) and inject each one via IntersectionObserver only when its section nears the viewport. Reduced-motion readers skip the matter-js download entirely and just get the server-rendered banner. The site still ships zero Astro-bundled / ES-module JavaScript — these are hand-vendored classic scripts injected by a runtime-created script element, so the `Verify zero external JavaScript` gate stays green. Homepage document: 358KB -> 264KB (brotli wire 94KB -> 51KB); the two runtimes now load lazily, cached immutably per versioned URL. * perf(landing): defer below-the-fold homepage art off the LCP path The Cloudflare RUM network trace showed homepage LCP (~2.4s, the hero webp) was starved of bandwidth: ~1.9MB of images — most of it below the fold — was fetched in the first ~700ms and competed with the hero for the connection. Culprits: two full-bleed section backdrops loaded via CSS `::before` (lab-stage-art 454KB, cta-bg 335KB), the Labs dock preloading ~500KB of preview stills on init, the contributor orbit building ~350KB of avatars on init, and a few raw eager <img>. Defer all of it until its section nears the viewport: - precise-lazyload gains a `data-precise-bg` mode (adds `.precise-bg-in`, which the CSS uses to gate the `::before` background-image — you can't set a pseudo-element's background from JS) and a configurable `imgRootMargin`. The homepage mounts it at 600px instead of the catalog default 1500px so its heavy art doesn't join the initial burst. - A shared `__whenNear(selector, cb)` helper gates the Labs dock (`enhanceLabSwitch`) and contributor orbit (`enhanceContributorOrbit`) init behind an IntersectionObserver. - `cta-window` gets `loading="lazy"`; the near-fold hero product shot gets `fetchpriority="low"` so the hero-bg (the LCP element) wins the pipe. Verified in a headless browser against the built site: initial requests drop 50 -> 20, the ~1.76MB of below-fold art is withheld until scrolled to (then loads on cue), CLS stays 0.001, and every enhancer still works (section backdrops paint, Labs dock switches, falling-text physics runs 23/23, globe renders, 16 contributor avatars build). * perf(landing): shrink oversized homepage backdrops to display size The two full-bleed section backdrops shipped far more pixels than they ever render: cta-bg was 2776×1554 (335KB) and lab-stage-art 2262×1358 (454KB), but both display at roughly 960px CSS — 1920px covers even a 2× retina panel. Re-encoded at 1920px wide, q82 webp: cta-bg.webp 335KB → 174KB (-48%) lab-stage-art.webp 454KB → 276KB (-39%) ~340KB saved with no visible quality loss (verified at 2× device scale — the painterly murals stay crisp). Only the pixel dimensions dropped; the quality factor is high. The homepage's gated `background-image` URLs bump their `?v=` so the immutable edge cache serves the new files immediately. * perf(landing): redirect locale from <head> so the wrong-locale first load aborts A non-English visitor hitting the English root ran the locale auto-redirect from a <script> late in <body>, so the browser had already streamed most of the document (and kicked off its head resources) before bouncing to /zh/ etc. — a wasted near-full first load. Move the auto-redirect decision to the top of <head> (first thing after the viewport/theme-color meta) so it fires as the document starts streaming and aborts the rest of the wrong-locale load. The switcher UI wiring, which needs the DOM, now defers itself to DOMContentLoaded, so it works whether the script runs in <head> (homepage) or late in <body> (other layouts). All existing guards are untouched — canonical-only pages, autoredirect-off pages, and already-localized roots still no-op, so English and crawler traffic pays only a tiny inline read. Verified: root / still redirects to /zh/ for a zh browser with no loop, and all 11 language-switcher links bind correctly post-DOMContentLoaded.
2163 lines
94 KiB
Plaintext
2163 lines
94 KiB
Plaintext
---
|
||
import Page from '../page';
|
||
import '../globals.css';
|
||
import { createRequire } from 'node:module';
|
||
import { createElement } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import FaviconLinks from '../_components/favicon-links.astro';
|
||
import SiteAnalytics from '../_components/site-analytics.astro';
|
||
import ResourceHints from '../_components/resource-hints.astro';
|
||
import LocaleSwitcherScript from '../_components/locale-switcher-script.astro';
|
||
import PreciseLazyload from '../_components/precise-lazyload.astro';
|
||
import {
|
||
heroBgImage,
|
||
heroBgSrcset,
|
||
ogDefaultImage,
|
||
OG_IMAGE_HEIGHT,
|
||
OG_IMAGE_WIDTH,
|
||
} from '../image-assets';
|
||
import {
|
||
LANDING_LOCALES,
|
||
alternateLinksForPath,
|
||
getHomeFaq,
|
||
getHomeSeo,
|
||
getLocaleDefinition,
|
||
localeFromPath,
|
||
localePath,
|
||
type LandingLocaleCode,
|
||
} from '../i18n';
|
||
import { getCatalogCounts } from '../_lib/catalog';
|
||
import { getGithubRepoMeta } from '../_lib/github';
|
||
import { clampDescription } from '../_lib/clamp-description';
|
||
|
||
const locale: LandingLocaleCode = localeFromPath(Astro.url.pathname);
|
||
const localeDef = getLocaleDefinition(locale);
|
||
const counts = await getCatalogCounts();
|
||
const github = await getGithubRepoMeta();
|
||
const { title, description } = getHomeSeo(locale, counts);
|
||
// Clamp the meta/OG/Twitter description; the full string still feeds JSON-LD.
|
||
const metaDescription = clampDescription(description);
|
||
const canonical = new URL(localePath(locale), Astro.site).toString();
|
||
const origin = Astro.site?.toString() ?? 'https://open-design.ai/';
|
||
const logoUrl = new URL('/android-chrome-512x512.png', Astro.site).toString();
|
||
const alternateLinks = alternateLinksForPath('/').map((entry) => ({
|
||
...entry,
|
||
href: new URL(entry.hrefPath, Astro.site).toString(),
|
||
}));
|
||
const xDefaultHref = new URL('/', Astro.site).toString();
|
||
const REPO_URL = 'https://github.com/nexu-io/open-design';
|
||
const RELEASES_URL = `${REPO_URL}/releases`;
|
||
const ISSUES_URL = `${REPO_URL}/issues`;
|
||
const DOCS_URL = `${REPO_URL}#readme`;
|
||
const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE`;
|
||
const DISCORD_URL = 'https://discord.gg/mHAjSMV6gz';
|
||
const OFFICIAL_URL = `${origin}official/`;
|
||
|
||
const websiteSchema = {
|
||
'@type': 'WebSite',
|
||
'@id': `${origin}#website`,
|
||
name: 'Open Design',
|
||
alternateName: ['OpenDesign', 'open-design', 'opendesign', 'Open Design AI', 'OD'],
|
||
url: origin,
|
||
inLanguage: localeDef.htmlLang,
|
||
availableLanguage: LANDING_LOCALES.map((entry) => entry.htmlLang),
|
||
publisher: { '@id': `${origin}#organization` },
|
||
};
|
||
|
||
const organizationSchema = {
|
||
'@type': 'Organization',
|
||
'@id': `${origin}#organization`,
|
||
name: 'Open Design',
|
||
alternateName: ['OpenDesign', 'open-design', 'opendesign', 'Open Design AI', 'OD', 'nexu-io/open-design'],
|
||
url: origin,
|
||
logo: {
|
||
'@type': 'ImageObject',
|
||
url: logoUrl,
|
||
width: 512,
|
||
height: 512,
|
||
},
|
||
// Five canonical pillars — Google uses sameAs to merge entity claims
|
||
// across sources. Listing the official site, GitHub repo, release
|
||
// feed, README docs, and Discord here prevents capture sites from
|
||
// splitting the brand entity.
|
||
sameAs: [REPO_URL, RELEASES_URL, DOCS_URL, DISCORD_URL, OFFICIAL_URL],
|
||
};
|
||
|
||
const softwareSchema = {
|
||
'@type': 'SoftwareApplication',
|
||
'@id': `${origin}#software`,
|
||
name: 'Open Design',
|
||
alternateName: ['OpenDesign', 'open-design', 'opendesign', 'Open Design AI', 'OD'],
|
||
description,
|
||
url: origin,
|
||
inLanguage: localeDef.htmlLang,
|
||
applicationCategory: 'DesignApplication',
|
||
operatingSystem: 'macOS, Windows, Linux',
|
||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
||
softwareVersion: github.versionLabel,
|
||
downloadUrl: RELEASES_URL,
|
||
installUrl: `${origin}quickstart/`,
|
||
softwareHelp: { '@type': 'CreativeWork', url: DOCS_URL },
|
||
releaseNotes: RELEASES_URL,
|
||
codeRepository: REPO_URL,
|
||
discussionUrl: DISCORD_URL,
|
||
issueTracker: ISSUES_URL,
|
||
sameAs: [REPO_URL, RELEASES_URL, DOCS_URL, DISCORD_URL, OFFICIAL_URL, LICENSE_URL],
|
||
offers: {
|
||
'@type': 'Offer',
|
||
price: '0',
|
||
priceCurrency: 'USD',
|
||
},
|
||
publisher: { '@id': `${origin}#organization` },
|
||
};
|
||
|
||
const homepageGraph = {
|
||
'@context': 'https://schema.org',
|
||
'@graph': [websiteSchema, organizationSchema, softwareSchema],
|
||
};
|
||
const faq = getHomeFaq(locale, { origin, repo: REPO_URL });
|
||
const pageHtml = renderToStaticMarkup(
|
||
Page({ counts, github, locale, faq }) as ReturnType<typeof createElement>,
|
||
);
|
||
|
||
// The homepage globe (cobe) and the Method-section FallingText (matter-js) are
|
||
// below-the-fold progressive enhancements. Rather than inline their full
|
||
// runtimes (~96KB combined) into every homepage document, they are vendored
|
||
// into `public/enhancers/*.js` (see `scripts/vendor-enhancers.ts`) and injected
|
||
// ON DEMAND by the loaders at the end of <body> — only once their section
|
||
// nears the viewport, and never at all for reduced-motion readers in the
|
||
// physics case. The site still ships ZERO Astro-bundled / ES-module JavaScript;
|
||
// these are hand-vendored classic scripts, not `/_astro/*.js` build output, so
|
||
// the `Verify zero external JavaScript` gate's intent holds (it greps the built
|
||
// HTML for a literal external-script tag, which the runtime injection never
|
||
// emits). The `?v=` query busts the immutable edge cache when versions bump.
|
||
const requireFromHere = createRequire(import.meta.url);
|
||
const matterEnhancerUrl = `/enhancers/matter.min.js?v=${
|
||
(requireFromHere('matter-js/package.json') as { version: string }).version
|
||
}`;
|
||
const cobeEnhancerUrl = `/enhancers/cobe.js?v=${
|
||
(requireFromHere('cobe/package.json') as { version: string }).version
|
||
}`;
|
||
---
|
||
|
||
<!doctype html>
|
||
<html lang={localeDef.htmlLang} dir={localeDef.dir}>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<meta name="theme-color" content="#efe7d2" />
|
||
|
||
{/*
|
||
* First in <head> on purpose: a non-English visitor on the English root is
|
||
* redirected to their locale before the browser streams the rest of the
|
||
* document, so it aborts the wasted first-load instead of fully rendering /
|
||
* and then bouncing to /zh/ (etc.). The switcher UI wiring inside defers
|
||
* itself to DOMContentLoaded. Localized roots and canonical / autoredirect
|
||
* off pages no-op, so English/crawler traffic pays only a tiny inline read.
|
||
*/}
|
||
<LocaleSwitcherScript />
|
||
|
||
<title>{title}</title>
|
||
<meta name="description" content={metaDescription} />
|
||
<link rel="canonical" href={canonical} />
|
||
{alternateLinks.map((entry) => (
|
||
<link rel="alternate" hreflang={entry.hreflang} href={entry.href} />
|
||
))}
|
||
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
|
||
|
||
<FaviconLinks />
|
||
<ResourceHints />
|
||
|
||
<SiteAnalytics />
|
||
|
||
{/*
|
||
* Hero LCP preload. Cloudflare Pages turns this <link rel="preload"> into
|
||
* a 103 Early Hints response automatically when Early Hints is enabled in
|
||
* the dashboard, so the browser starts the image fetch before the HTML
|
||
* body finishes streaming.
|
||
*
|
||
* Only emitted on `/` — the rest of the site uses lighter hero treatment.
|
||
*/}
|
||
<link
|
||
rel="preload"
|
||
as="image"
|
||
href={heroBgImage}
|
||
imagesrcset={heroBgSrcset}
|
||
imagesizes="100vw"
|
||
fetchpriority="high"
|
||
/>
|
||
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:site_name" content="Open Design" />
|
||
<meta property="og:title" content={title} />
|
||
<meta property="og:description" content={metaDescription} />
|
||
<meta property="og:url" content={canonical} />
|
||
<meta property="og:image" content={ogDefaultImage} />
|
||
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
|
||
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
|
||
<meta property="og:image:type" content="image/png" />
|
||
<meta property="og:locale" content={localeDef.ogLocale} />
|
||
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
|
||
<meta property="og:locale:alternate" content={entry.ogLocale} />
|
||
))}
|
||
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:title" content={title} />
|
||
<meta name="twitter:description" content={metaDescription} />
|
||
<meta name="twitter:image" content={ogDefaultImage} />
|
||
|
||
{/*
|
||
* Single @graph JSON-LD block — WebSite + Organization +
|
||
* SoftwareApplication.
|
||
*/}
|
||
<script is:inline type="application/ld+json" set:html={JSON.stringify(homepageGraph)} />
|
||
</head>
|
||
<body>
|
||
<Fragment set:html={pageHtml} />
|
||
{/*
|
||
* Tighter than the catalog default (1500px): the homepage's heavy
|
||
* below-the-fold art — full-bleed section backdrops, product shots — must
|
||
* not join the initial request burst and starve the hero LCP of bandwidth.
|
||
* 600px still pre-loads roughly one viewport ahead so scrolling stays warm.
|
||
*/}
|
||
<PreciseLazyload imgRootMargin="600px 0px" />
|
||
{/*
|
||
* Enhancement infrastructure — defined before any enhancer runs so the
|
||
* below-the-fold defer helpers are available to every script that follows.
|
||
*/}
|
||
<script is:inline define:vars={{ matterEnhancerUrl, cobeEnhancerUrl }}>
|
||
window.__enhancerUrls = { matter: matterEnhancerUrl, cobe: cobeEnhancerUrl };
|
||
// Run `cb` once any element matching `selector` nears the viewport (one
|
||
// shot). Used to defer below-the-fold enhancers whose init eagerly pulls
|
||
// heavy art — the Labs dock preloads its ~500KB of preview stills, the
|
||
// contributor orbit builds ~350KB of avatars — so that art no longer
|
||
// competes with the hero LCP for bandwidth on first load.
|
||
window.__whenNear = (selector, cb, rootMargin) => {
|
||
const els = document.querySelectorAll(selector);
|
||
if (!els.length) return;
|
||
if (!('IntersectionObserver' in window)) {
|
||
cb();
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
io.disconnect();
|
||
cb();
|
||
}
|
||
},
|
||
{ rootMargin: rootMargin || '0px 0px 150% 0px' },
|
||
);
|
||
els.forEach((el) => io.observe(el));
|
||
};
|
||
// On-demand loader for the vendored enhancement runtimes (matter-js /
|
||
// cobe). Injects each classic script at most once and resolves once it
|
||
// has attached its global (`window.Matter` / `window.__cobe`). The script
|
||
// element is created at runtime (never authored as static markup), so the
|
||
// built HTML carries no external-script tag and the `Verify zero external
|
||
// JavaScript` gate stays green while ~96KB of decorative runtime leaves
|
||
// the critical document.
|
||
window.__loadEnhancer = (src) => {
|
||
const cache = (window.__enhancerCache = window.__enhancerCache || {});
|
||
return (
|
||
cache[src] ||
|
||
(cache[src] = new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = src;
|
||
s.async = true;
|
||
s.onload = () => resolve();
|
||
s.onerror = reject;
|
||
document.head.appendChild(s);
|
||
}))
|
||
);
|
||
};
|
||
</script>
|
||
<script is:inline>
|
||
(() => {
|
||
const formatStars = (count) => {
|
||
if (!Number.isFinite(count) || count <= 0) return '0';
|
||
if (count < 1000) return String(count);
|
||
return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
||
};
|
||
|
||
// Pull a clean 'v0.3.0'-style label from a GitHub release record.
|
||
// We prefer release.name (e.g. 'Open Design 0.3.0') because that's
|
||
// what we hand-author; fall back to tag_name (e.g.
|
||
// 'open-design-v0.3.0') with the project prefix stripped.
|
||
//
|
||
// Expected input shapes (release.name / release.tag_name):
|
||
// { name: 'Open Design 0.3.0', tag_name: 'v0.3.0' } → 'v0.3.0'
|
||
// { name: 'Open Design v0.3.0', tag_name: 'open-design-v0.3.0' } → 'v0.3.0'
|
||
// { name: '0.3.0-beta.1', tag_name: 'open-design_0.3.0' } → 'v0.3.0-beta.1' (name wins)
|
||
// { name: null, tag_name: 'open-design-v0.3.0' } → 'v0.3.0' (tag fallback)
|
||
// { name: null, tag_name: null } → null (caller skips)
|
||
const formatVersion = (release) => {
|
||
const fromTag = (tag) => {
|
||
if (typeof tag !== 'string') return null;
|
||
const cleaned = tag.replace(/^open-design[-_]?v?/i, '').trim();
|
||
return cleaned ? `v${cleaned.replace(/^v/, '')}` : null;
|
||
};
|
||
const fromName = (name) => {
|
||
if (typeof name !== 'string') return null;
|
||
const m = name.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/);
|
||
return m ? `v${m[1]}` : null;
|
||
};
|
||
return fromName(release?.name) ?? fromTag(release?.tag_name) ?? null;
|
||
};
|
||
|
||
const enhanceHeader = () => {
|
||
const chrome = document.querySelector('[data-chrome-headroom]');
|
||
if (chrome) {
|
||
const navBar = chrome.querySelector('.nav');
|
||
const glassMap = chrome.querySelector('[data-nav-glass-map]');
|
||
|
||
// Liquid Glass displacement map — ported 1:1 from Inspira UI's
|
||
// LiquidGlass.vue `displacementImage` computed (radius 16, border
|
||
// 0.07, lightness 50, alpha 0.93, blur 11, blend "difference").
|
||
// Built at the live bar's pixel size so the edge refraction
|
||
// tracks the bar as it condenses; mirrors the component's
|
||
// ResizeObserver-driven data URI.
|
||
const buildGlassMap = (w, h) => {
|
||
// Pill radius — half the bar height — so the refraction follows
|
||
// the condensed capsule's fully rounded ends.
|
||
const radius = Math.round(Math.min(w, h) / 2);
|
||
const borderRatio = 0.07;
|
||
const lightness = 50;
|
||
const alpha = 0.93;
|
||
const blur = 11;
|
||
const blend = 'difference';
|
||
const inset = Math.min(w, h) * (borderRatio * 0.5);
|
||
// Opaque black (#000), NOT transparent (#0000): a transparent->red
|
||
// stop interpolates with straight alpha, so over black the R
|
||
// channel comes out quadratic (255*t^2) and its 0.5 neutral point
|
||
// lands at ~29% instead of 50%, shifting the refraction to one
|
||
// side. Opaque stops give a linear ramp centered at 50%. (Mirror
|
||
// of the same map builder in _components/header-enhancer.astro.)
|
||
const svg =
|
||
'<svg viewBox="0 0 ' + w + ' ' + h + '" xmlns="http://www.w3.org/2000/svg">' +
|
||
'<defs>' +
|
||
'<linearGradient id="red" x1="100%" y1="0%" x2="0%" y2="0%"><stop offset="0%" stop-color="#000"/><stop offset="100%" stop-color="red"/></linearGradient>' +
|
||
'<linearGradient id="blue" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#000"/><stop offset="100%" stop-color="blue"/></linearGradient>' +
|
||
'</defs>' +
|
||
'<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="black"/>' +
|
||
'<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + radius + '" fill="url(#red)"/>' +
|
||
'<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + radius + '" fill="url(#blue)" style="mix-blend-mode:' + blend + '"/>' +
|
||
'<rect x="' + inset + '" y="' + inset + '" width="' + (w - inset * 2) + '" height="' + (h - inset * 2) + '" rx="' + radius + '" fill="hsl(0 0% ' + lightness + '% / ' + alpha + ')" style="filter:blur(' + blur + 'px)"/>' +
|
||
'</svg>';
|
||
return 'data:image/svg+xml,' + encodeURIComponent(svg);
|
||
};
|
||
|
||
const syncGlassMap = () => {
|
||
if (!navBar || !glassMap) return;
|
||
const rect = navBar.getBoundingClientRect();
|
||
const w = Math.max(1, Math.round(rect.width));
|
||
const h = Math.max(1, Math.round(rect.height));
|
||
const uri = buildGlassMap(w, h);
|
||
glassMap.setAttribute('href', uri);
|
||
glassMap.setAttributeNS('http://www.w3.org/1999/xlink', 'href', uri);
|
||
};
|
||
|
||
// Debounced map rebuild — the capsule's width/height animate on
|
||
// condense, so rebuilding the displacement SVG every frame would
|
||
// re-rasterize the filter each frame and stutter. Rebuild once the
|
||
// size settles; the prior map covers the brief morph.
|
||
let mapTimer = 0;
|
||
const scheduleGlassMap = () => {
|
||
clearTimeout(mapTimer);
|
||
mapTimer = setTimeout(syncGlassMap, 140);
|
||
};
|
||
|
||
// Condense-on-scroll with hysteresis: condense past 64px, release
|
||
// below 24px. The dead-band stops a scroll that lingers near a
|
||
// single threshold from flipping the state — and the bar's
|
||
// geometry — back and forth (the "jitter"). rAF collapses each
|
||
// scroll burst to one read + at most one class change per frame.
|
||
const condenseOn = 64;
|
||
const condenseOff = 24;
|
||
let condensed = false;
|
||
let ticking = false;
|
||
const onScroll = () => {
|
||
ticking = false;
|
||
const y = window.scrollY;
|
||
if (!condensed && y > condenseOn) {
|
||
condensed = true;
|
||
chrome.classList.add('is-condensed');
|
||
} else if (condensed && y < condenseOff) {
|
||
condensed = false;
|
||
chrome.classList.remove('is-condensed');
|
||
}
|
||
};
|
||
window.addEventListener(
|
||
'scroll',
|
||
() => {
|
||
if (ticking) return;
|
||
ticking = true;
|
||
requestAnimationFrame(onScroll);
|
||
},
|
||
{ passive: true },
|
||
);
|
||
condensed = window.scrollY > condenseOn;
|
||
chrome.classList.toggle('is-condensed', condensed);
|
||
|
||
syncGlassMap();
|
||
if (navBar) {
|
||
if (window.ResizeObserver) {
|
||
new ResizeObserver(scheduleGlassMap).observe(navBar);
|
||
}
|
||
window.addEventListener('resize', scheduleGlassMap, { passive: true });
|
||
}
|
||
}
|
||
|
||
const starSlots = document.querySelectorAll('[data-github-stars]');
|
||
if (starSlots.length) {
|
||
fetch('https://api.github.com/repos/nexu-io/open-design', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||
.then((data) => {
|
||
if (typeof data?.stargazers_count === 'number') {
|
||
const label = formatStars(data.stargazers_count);
|
||
for (const slot of starSlots) slot.textContent = label;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Live contributor count for the testimonial headline ("N 贡献者").
|
||
// The total is the last page number when paginating one-per-page; if
|
||
// there's no Link header the list fits on one page, so its length is
|
||
// the total. Static "100+" fallback stays if the request fails / 403s.
|
||
const contribSlots = document.querySelectorAll('[data-github-contributors]');
|
||
if (contribSlots.length) {
|
||
fetch('https://api.github.com/repos/nexu-io/open-design/contributors?per_page=1', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error('http error');
|
||
const link = r.headers.get('link') || '';
|
||
const last = link.match(/[?&]page=(\d+)>;\s*rel="last"/);
|
||
if (last) return parseInt(last[1], 10);
|
||
return r.json().then((arr) => (Array.isArray(arr) ? arr.length : null));
|
||
})
|
||
.then((n) => {
|
||
if (typeof n === 'number' && n > 0) {
|
||
for (const slot of contribSlots) slot.textContent = String(n);
|
||
// Feed the same live total into the "贡献者" stat card. Updating
|
||
// `data-countup-to` retargets the roll-up if it hasn't run yet;
|
||
// setting the text keeps the fallback correct (and fixes the
|
||
// value if the roll already finished before this resolved).
|
||
const card = document.querySelector('[data-github-contributors-countup]');
|
||
if (card) {
|
||
card.dataset.countupTo = String(n);
|
||
card.textContent = String(n) + (card.dataset.countupSuffix || '');
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Latest stable release powers every "v0.x.y" badge on the page
|
||
// (topbar pulse, hero CTA-foot, footer download). Hits one
|
||
// unauthenticated API call per page view; the static fallback in
|
||
// each slot keeps the layout sane if the request fails or 403s.
|
||
const versionSlots = document.querySelectorAll('[data-github-version]');
|
||
if (versionSlots.length === 0) return;
|
||
fetch('https://api.github.com/repos/nexu-io/open-design/releases/latest', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||
.then((data) => {
|
||
const label = formatVersion(data);
|
||
if (!label) return;
|
||
for (const slot of versionSlots) slot.textContent = label;
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
const enhanceWire = () => {
|
||
const track = document.querySelector('[data-wire-contributors-track]');
|
||
const count = document.querySelector('[data-wire-contributors-count]');
|
||
if (!track) return;
|
||
|
||
const roleOverrides = {
|
||
tw93: 'kami',
|
||
op7418: 'guizang',
|
||
alchaincyf: 'huashu',
|
||
OpenCoworkAI: 'codesign',
|
||
'nexu-io': 'studio',
|
||
lewislulu: 'html-ppt',
|
||
};
|
||
const roleFor = (login, contributions) =>
|
||
roleOverrides[login] ?? `${contributions} ${contributions === 1 ? 'commit' : 'commits'}`;
|
||
const isContributor = (value) =>
|
||
value &&
|
||
typeof value.login === 'string' &&
|
||
typeof value.html_url === 'string' &&
|
||
typeof value.type === 'string' &&
|
||
typeof value.contributions === 'number';
|
||
const renderContributor = (contributor, index) => {
|
||
const link = document.createElement('a');
|
||
link.className = 'wire-item is-link';
|
||
link.href = contributor.href;
|
||
link.target = '_blank';
|
||
link.rel = 'noreferrer noopener';
|
||
link.setAttribute('aria-label', `Open ${contributor.handle} on GitHub`);
|
||
link.dataset.liveWireItem = String(index);
|
||
|
||
const dot = document.createElement('span');
|
||
dot.className = 'wire-dot';
|
||
dot.textContent = '·';
|
||
const handle = document.createElement('span');
|
||
handle.className = 'wire-handle';
|
||
handle.textContent = `@${contributor.handle}`;
|
||
const role = document.createElement('span');
|
||
role.className = 'wire-role';
|
||
role.textContent = contributor.role;
|
||
|
||
link.append(dot, handle, role);
|
||
return link;
|
||
};
|
||
|
||
fetch('https://api.github.com/repos/nexu-io/open-design/contributors?per_page=12', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||
.then((data) => {
|
||
if (!Array.isArray(data)) return;
|
||
const live = data
|
||
.filter(isContributor)
|
||
.filter((c) => c.type !== 'Bot' && !c.login.endsWith('[bot]'))
|
||
.slice(0, 12)
|
||
.map((c) => ({
|
||
handle: c.login,
|
||
role: roleFor(c.login, c.contributions),
|
||
href: c.html_url,
|
||
}));
|
||
if (live.length === 0) return;
|
||
live.push({
|
||
handle: 'you',
|
||
role: 'be next',
|
||
href: 'https://github.com/nexu-io/open-design/graphs/contributors',
|
||
});
|
||
if (count) count.textContent = String(Math.max(0, live.length - 1));
|
||
track.replaceChildren(
|
||
...[...live, ...live].map((contributor, index) => renderContributor(contributor, index)),
|
||
);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
// ABOUT statement — "Text Scroll Reveal" (Magic UI / Inspira port).
|
||
// The copy is a sticky, vertically-centered paragraph inside a tall
|
||
// track (`[data-about-reveal]`); as the reader scrolls through, each
|
||
// token brightens in turn. Progress is the track top's travel past
|
||
// the viewport top (`-rect.top / innerHeight`), and token i lights
|
||
// over the range [i/n, (i+1)/n] — exactly the component's mapping.
|
||
const enhanceStatementReveal = () => {
|
||
const host = document.querySelector('[data-about-reveal]');
|
||
if (!host) return;
|
||
const words = host.querySelectorAll('[data-reveal-word]');
|
||
if (!words.length) return;
|
||
// Reduced motion: leave the copy at full ink (no scroll dependence).
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
|
||
host.classList.add('is-reveal-active');
|
||
const n = words.length;
|
||
let ticking = false;
|
||
const update = () => {
|
||
ticking = false;
|
||
const rect = host.getBoundingClientRect();
|
||
// Non-pinned mapping: reveal as the block travels up through the
|
||
// viewport. progress 0 when its top sits at 82% of the viewport
|
||
// (just risen into view), 1 by the time it reaches 22% (near the
|
||
// top) — so the copy lights word by word while it's on screen,
|
||
// without a tall pinned track.
|
||
const vh = window.innerHeight;
|
||
const start = vh * 0.82;
|
||
const end = vh * 0.22;
|
||
const progress = (start - rect.top) / (start - end);
|
||
for (let i = 0; i < n; i++) {
|
||
const start = i / n;
|
||
const end = (i + 1) / n;
|
||
let o;
|
||
if (progress <= start) o = 0;
|
||
else if (progress >= end) o = 1;
|
||
else o = (progress - start) / (end - start);
|
||
// Base faint 0.18 → full 1 (the dim-ghost → lit look).
|
||
words[i].style.opacity = String(0.18 + 0.82 * o);
|
||
}
|
||
};
|
||
const onScroll = () => {
|
||
if (ticking) return;
|
||
ticking = true;
|
||
requestAnimationFrame(update);
|
||
};
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
window.addEventListener('resize', onScroll, { passive: true });
|
||
update();
|
||
};
|
||
|
||
// Labs filter Dock — macOS-style proximity magnification (React Bits
|
||
// "Dock", vanilla port). Each item scales by its distance to the
|
||
// cursor using a triangular falloff over `dist` px; a short CSS
|
||
// transition stands in for the spring. Centers come from offset
|
||
// geometry (unaffected by the scale transform), so there's no
|
||
// feedback wobble. rAF-throttled; resets on leave; no-ops under
|
||
// reduced-motion.
|
||
// Lab clips ship as VP9 WebM (sharp at a fraction of the H.264 size) with
|
||
// the original .mp4 kept as a fallback. Prefer WebM where the browser can
|
||
// decode it (Chrome/Edge/Firefox/Safari 16+); older Safari keeps the mp4.
|
||
// `data-preview-video` carries the .mp4 path; this swaps the extension.
|
||
const labPrefersWebm = (() => {
|
||
try {
|
||
const v = document.createElement('video');
|
||
return !!(v.canPlayType && v.canPlayType('video/webm; codecs="vp9"'));
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
})();
|
||
const labClipSrc = (src) =>
|
||
labPrefersWebm && /\.mp4$/i.test(src || '') ? src.replace(/\.mp4$/i, '.webm') : src;
|
||
|
||
const enhanceLabDock = () => {
|
||
const dock = document.querySelector('[data-lab-dock]');
|
||
if (!dock) return;
|
||
// Prefetch the lab clips (Video / HyperFrames tiles) so switching to
|
||
// them is instant rather than waiting 1-2s for the first byte — but
|
||
// only once the lab section is about to scroll into view, not on page
|
||
// load. Most visitors who never reach the lab never pay the ~1.7MB.
|
||
// An IntersectionObserver with a one-viewport bottom margin fires the
|
||
// fetch just before the dock enters, then disconnects (one-shot).
|
||
// Falls back to an idle prefetch where IntersectionObserver is absent.
|
||
(function prefetchLabVideos() {
|
||
const srcs = Array.from(dock.querySelectorAll('[data-preview-video]'))
|
||
.map((el) => el.getAttribute('data-preview-video'))
|
||
.filter(Boolean)
|
||
.map(labClipSrc);
|
||
if (srcs.length === 0) return;
|
||
let done = false;
|
||
const run = () => {
|
||
if (done) return;
|
||
done = true;
|
||
srcs.forEach((src) => { try { fetch(src).catch(() => {}); } catch (e) {} });
|
||
};
|
||
if ('IntersectionObserver' in window) {
|
||
const io = new IntersectionObserver((entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
io.disconnect();
|
||
run();
|
||
}
|
||
}, { rootMargin: '0px 0px 100% 0px' });
|
||
io.observe(dock);
|
||
} else if ('requestIdleCallback' in window) {
|
||
requestIdleCallback(run, { timeout: 4000 });
|
||
} else {
|
||
setTimeout(run, 2500);
|
||
}
|
||
})();
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
const items = Array.from(dock.querySelectorAll('[data-dock-item]'));
|
||
if (items.length === 0) return;
|
||
const MAX = 1.08; // peak scale (subtle pop, never towers over neighbours)
|
||
const DIST = 104; // px of cursor proximity that still magnifies
|
||
let ticking = false;
|
||
const apply = (cursorX) => {
|
||
const dockLeft = dock.getBoundingClientRect().left;
|
||
const x = cursorX - dockLeft;
|
||
for (const it of items) {
|
||
const center = it.offsetLeft + it.offsetWidth / 2;
|
||
const t = Math.max(0, 1 - Math.abs(x - center) / DIST);
|
||
// Keep the selected tile's CSS lift (translateY(-20px)) while we
|
||
// drive the magnify scale via inline transform.
|
||
const lift = it.classList.contains('active') ? 'translateY(-20px) ' : '';
|
||
it.style.transform = `${lift}scale(${(1 + (MAX - 1) * t).toFixed(3)})`;
|
||
}
|
||
};
|
||
dock.addEventListener(
|
||
'mousemove',
|
||
(event) => {
|
||
if (ticking) return;
|
||
ticking = true;
|
||
requestAnimationFrame(() => {
|
||
apply(event.clientX);
|
||
ticking = false;
|
||
});
|
||
},
|
||
{ passive: true },
|
||
);
|
||
dock.addEventListener('mouseleave', () => {
|
||
for (const it of items) it.style.transform = '';
|
||
});
|
||
};
|
||
|
||
// Labs Dock — the tabs switch the command-window preview (image + title
|
||
// overlay) in place, no navigation. The active mode also auto-advances
|
||
// left→right as a self-running showcase: it pauses on hover, only runs
|
||
// while the dock is on screen, and the auto-advance is disabled under
|
||
// reduced-motion (the click-to-switch behaviour stays active).
|
||
const enhanceLabAutoCycle = () => {
|
||
const dock = document.querySelector('[data-lab-dock]');
|
||
if (!dock) return;
|
||
const autoPlay = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const items = Array.from(dock.querySelectorAll('[data-dock-item]'));
|
||
if (items.length < 2) return;
|
||
let previewImg = document.querySelector('[data-lab-preview] img');
|
||
// The Labs preview is now a CSS background on `.lab-stage` (no inner
|
||
// <img>), so this legacy <img> carousel no longer applies — switching
|
||
// is handled by `enhanceLabSwitch`. Bail before it touches a null img.
|
||
if (!previewImg) return;
|
||
const viewport = previewImg ? previewImg.parentElement : null;
|
||
const previewTitle = document.querySelector('[data-lab-preview-title]');
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// Preload every preview so switches are instant, no load flash.
|
||
for (const it of items) {
|
||
const src = it.getAttribute('data-preview-src');
|
||
if (src) {
|
||
const pre = new Image();
|
||
pre.src = src;
|
||
}
|
||
}
|
||
|
||
let index = items.findIndex((it) => it.classList.contains('active'));
|
||
if (index < 0) index = 0;
|
||
|
||
// Mark the active tile + sync the floating title for index `i`.
|
||
const setMeta = (i) => {
|
||
items.forEach((it, k) => it.classList.toggle('active', k === i));
|
||
const title = items[i].getAttribute('data-preview-title');
|
||
if (previewTitle && title) previewTitle.textContent = title;
|
||
};
|
||
|
||
// Swap the preview src in place — used for the initial paint and as
|
||
// the reduced-motion / same-index fallback.
|
||
const swapInPlace = (i) => {
|
||
setMeta(i);
|
||
const src = items[i].getAttribute('data-preview-src');
|
||
if (previewImg && src) {
|
||
previewImg.setAttribute('src', src);
|
||
// Keep the precise-lazyload observer from reverting our swap.
|
||
previewImg.setAttribute('data-precise-src', src);
|
||
}
|
||
};
|
||
|
||
const SLIDE_EASE = 'transform 560ms cubic-bezier(0.23, 1, 0.32, 1)';
|
||
|
||
// One PERSISTENT sliding track holds the images side by side; we only
|
||
// ever translate this single element and add/remove child images. It is
|
||
// never torn down or re-parented, which is what avoids the end-of-slide
|
||
// flash: previously `track.replaceWith(...)` destroyed a `will-change`
|
||
// compositor layer, and for one frame the compositor showed the stale
|
||
// texture (the outgoing image parked in the left gap) before repaint.
|
||
let track = null;
|
||
const ensureTrack = () => {
|
||
if (track) return;
|
||
track = document.createElement('div');
|
||
track.style.position = 'absolute';
|
||
track.style.inset = '0';
|
||
track.style.display = 'flex';
|
||
track.style.width = '100%';
|
||
track.style.willChange = 'transform';
|
||
track.style.transform = 'translateX(0)';
|
||
previewImg.style.flex = '0 0 100%';
|
||
previewImg.style.width = '100%';
|
||
viewport.appendChild(track);
|
||
track.appendChild(previewImg); // current image becomes the lone child
|
||
};
|
||
|
||
// The currently-running slide's finaliser, so a new slide (rapid click)
|
||
// can settle the previous one instantly before starting.
|
||
let activeCleanup = null;
|
||
|
||
// Slide to preview `i`: forward (dir +1) brings the incoming in from the
|
||
// right, backward (-1) from the left. The two images share the one track
|
||
// and move as a single layer, so their shared edge stays pixel-perfect
|
||
// (no seam / no 底图 bleed-through).
|
||
const slideTo = (i, dir) => {
|
||
const src = items[i].getAttribute('data-preview-src');
|
||
if (!previewImg || !viewport || reduceMotion || dir === 0 || !src) {
|
||
swapInPlace(i);
|
||
return;
|
||
}
|
||
// Settle any in-flight slide so we start from a clean resting state.
|
||
if (activeCleanup) activeCleanup();
|
||
setMeta(i);
|
||
ensureTrack();
|
||
|
||
const incoming = previewImg.cloneNode(false);
|
||
incoming.setAttribute('src', src);
|
||
// Src is already set, so the precise-lazyload observer has nothing to
|
||
// do — drop the attr so its MutationObserver ignores the clone.
|
||
incoming.removeAttribute('data-precise-src');
|
||
incoming.style.flex = '0 0 100%';
|
||
incoming.style.width = '100%';
|
||
|
||
const run = () => {
|
||
if (activeCleanup) activeCleanup();
|
||
const outgoing = previewImg;
|
||
previewImg = incoming;
|
||
const forward = dir > 0;
|
||
|
||
// Park the incoming next to the outgoing and show the outgoing.
|
||
track.style.transition = 'none';
|
||
if (forward) {
|
||
track.appendChild(incoming); // incoming is the right neighbour
|
||
track.style.transform = 'translateX(0)';
|
||
} else {
|
||
track.insertBefore(incoming, track.firstChild); // left neighbour
|
||
track.style.transform = 'translateX(-100%)';
|
||
}
|
||
void track.offsetWidth; // commit start state before animating
|
||
|
||
// Animate the strip so the incoming lands centered.
|
||
track.style.transition = SLIDE_EASE;
|
||
track.style.transform = forward ? 'translateX(-100%)' : 'translateX(0)';
|
||
|
||
let cleaned = false;
|
||
const cleanup = () => {
|
||
if (cleaned) return;
|
||
cleaned = true;
|
||
activeCleanup = null;
|
||
track.removeEventListener('transitionend', cleanup);
|
||
// Remove the outgoing child and rebase the (persistent) track to
|
||
// 0 in the SAME synchronous block — no repaint in between, and the
|
||
// track layer is never destroyed, so nothing flashes.
|
||
track.style.transition = 'none';
|
||
if (outgoing.parentNode === track) track.removeChild(outgoing);
|
||
track.style.transform = 'translateX(0)';
|
||
};
|
||
activeCleanup = cleanup;
|
||
track.addEventListener('transitionend', cleanup, { once: true });
|
||
window.setTimeout(cleanup, 700); // fallback if transitionend missed
|
||
};
|
||
|
||
// Decode the incoming bitmap BEFORE animating so it never paints a
|
||
// blank frame mid-slide. Images are preloaded, so decode resolves
|
||
// almost immediately; fall back to an immediate slide otherwise.
|
||
if (typeof incoming.decode === 'function') {
|
||
incoming.decode().then(run, run);
|
||
} else {
|
||
run();
|
||
}
|
||
};
|
||
swapInPlace(index);
|
||
|
||
const INTERVAL = 2400;
|
||
let timer = null;
|
||
let paused = false;
|
||
const tick = () => {
|
||
if (paused) return;
|
||
const next = (index + 1) % items.length;
|
||
slideTo(next, 1); // auto-advance always slides forward (left)
|
||
index = next;
|
||
};
|
||
const start = () => {
|
||
if (autoPlay && timer == null) timer = window.setInterval(tick, INTERVAL);
|
||
};
|
||
const stop = () => {
|
||
if (timer != null) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
dock.addEventListener('mouseenter', () => { paused = true; });
|
||
dock.addEventListener('mouseleave', () => { paused = false; });
|
||
|
||
// Clicking a tab slides the preview toward the picked tab — no
|
||
// navigation. Direction follows whether the target sits left or right
|
||
// of the current one. Restart the dwell so it stays put for a full
|
||
// interval.
|
||
items.forEach((it, i) => {
|
||
it.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
const dir = i === index ? 0 : i > index ? 1 : -1;
|
||
slideTo(i, dir);
|
||
index = i;
|
||
if (timer != null) {
|
||
stop();
|
||
start();
|
||
}
|
||
});
|
||
});
|
||
|
||
if ('IntersectionObserver' in window) {
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) start();
|
||
else stop();
|
||
}
|
||
},
|
||
{ threshold: 0.2 },
|
||
);
|
||
io.observe(dock);
|
||
} else {
|
||
start();
|
||
}
|
||
};
|
||
|
||
// Roll the work-stats numbers up on scroll-into-view. The data lives
|
||
// as DOM text overlaid on the painterly cards (the values used to be
|
||
// baked into the card images). This page ships ZERO client React
|
||
// (renderToStaticMarkup, no @astrojs/react), so the count-up is driven
|
||
// here.
|
||
//
|
||
// Robustness contract: each [data-countup] span renders its FINAL value
|
||
// in static HTML. The roll is a pure enhancement — we reset to `from`
|
||
// ONLY at the moment the element enters view, inside run(). If the
|
||
// observer never fires (reduced motion, no IO, scrolled past before a
|
||
// frame) the final value is left untouched, so a number can never get
|
||
// stuck blank or at 0.
|
||
const enhanceCountups = () => {
|
||
const nodes = document.querySelectorAll('[data-countup]');
|
||
if (nodes.length === 0) return;
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
if (reduceMotion || !('IntersectionObserver' in window)) return;
|
||
|
||
const decimalsOf = (value) => {
|
||
const str = String(value);
|
||
return str.includes('.') ? str.split('.')[1].length : 0;
|
||
};
|
||
const makeFormatter = (el, to) => {
|
||
const suffix = el.dataset.countupSuffix || '';
|
||
const separator = el.dataset.countupSeparator || '';
|
||
const decimals = decimalsOf(to);
|
||
return (value) => {
|
||
const formatted = new Intl.NumberFormat('en-US', {
|
||
useGrouping: Boolean(separator),
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals,
|
||
}).format(value);
|
||
return (separator ? formatted.replace(/,/g, separator) : formatted) + suffix;
|
||
};
|
||
};
|
||
const ease = (t) => 1 - Math.pow(1 - t, 4);
|
||
|
||
const done = new WeakSet();
|
||
const run = (el) => {
|
||
if (done.has(el)) return;
|
||
done.add(el);
|
||
const to = Number(el.dataset.countupTo);
|
||
if (!Number.isFinite(to)) return;
|
||
const from = Number(el.dataset.countupFrom || '0');
|
||
const duration = Math.max(0, Number(el.dataset.countupDuration || '2')) * 1000;
|
||
const format = makeFormatter(el, to);
|
||
let startTs = null;
|
||
const tick = (ts) => {
|
||
if (startTs === null) startTs = ts;
|
||
const progress = Math.min(1, (ts - startTs) / duration);
|
||
el.textContent = format(from + (to - from) * ease(progress));
|
||
if (progress < 1) requestAnimationFrame(tick);
|
||
else el.textContent = format(to);
|
||
};
|
||
requestAnimationFrame(tick);
|
||
};
|
||
|
||
const countObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue;
|
||
run(entry.target);
|
||
countObserver.unobserve(entry.target);
|
||
}
|
||
},
|
||
{ threshold: 0, rootMargin: '0px 0px -8% 0px' },
|
||
);
|
||
for (const el of nodes) countObserver.observe(el);
|
||
};
|
||
|
||
// Platform-aware download buttons. Detects OS and — on macOS — the
|
||
// chip family (Apple Silicon vs Intel), resolves the matching asset from
|
||
// the latest GitHub release, and rewrites every [data-download-cta]
|
||
// href to a direct download. Only buttons marked with
|
||
// [data-download-chip-target] show the detected platform chip.
|
||
// Falls back to the server-rendered releases-page href when detection or
|
||
// the API fails.
|
||
const enhanceDownloadCta = () => {
|
||
// `[data-download-page]` CTAs (e.g. the nav "Download" button) must
|
||
// keep their server-rendered href to the /download/ page — only the
|
||
// direct-download CTAs (hero) are rewritten to the release asset.
|
||
const buttons = Array.from(
|
||
document.querySelectorAll('[data-download-cta]:not([data-download-page])'),
|
||
);
|
||
if (buttons.length === 0) return;
|
||
const ua = navigator.userAgent || '';
|
||
const isWin = /Windows|Win32|Win64|WOW64/i.test(ua);
|
||
const isMac = /Macintosh|Mac OS X/i.test(ua) && !/iPhone|iPad|iPod/i.test(ua);
|
||
|
||
const labelChip = (text) => {
|
||
const chipTargets = buttons.filter((button) => button.hasAttribute('data-download-chip-target'));
|
||
chipTargets.forEach((btn) => {
|
||
let chip = btn.querySelector('[data-download-chip]');
|
||
if (!chip) {
|
||
chip = document.createElement('span');
|
||
chip.setAttribute('data-download-chip', '');
|
||
chip.style.opacity = '0.7';
|
||
chip.style.fontWeight = '400';
|
||
// The download icon now LEADS the button, so the chip belongs at
|
||
// the end — right after the label text.
|
||
btn.appendChild(chip);
|
||
}
|
||
chip.textContent = ' · ' + text;
|
||
});
|
||
};
|
||
|
||
// Resolve the direct-download href from the latest release. Best
|
||
// effort and DECOUPLED from the label: the chip text shows the moment
|
||
// the platform is detected, even if this fetch is rate-limited or the
|
||
// network is down (the button keeps its releases-page fallback href).
|
||
const setHref = (matcher) => {
|
||
if (!matcher) return;
|
||
fetch('https://api.github.com/repos/nexu-io/open-design/releases/latest', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((rel) => {
|
||
if (!rel || !Array.isArray(rel.assets)) return;
|
||
const asset = rel.assets.find((a) => matcher(a.name || ''));
|
||
if (asset && asset.browser_download_url) {
|
||
buttons.forEach((btn) => {
|
||
btn.href = asset.browser_download_url;
|
||
btn.setAttribute('download', '');
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
// Label first (always visible), then wire the direct link.
|
||
const apply = (label, matcher) => {
|
||
labelChip(label);
|
||
setHref(matcher);
|
||
};
|
||
|
||
const detectMacArch = async () => {
|
||
// Chromium exposes the real architecture; Safari does not, so fall
|
||
// back to the WebGL renderer string (Apple Silicon reports "Apple").
|
||
try {
|
||
const uad = navigator.userAgentData;
|
||
if (uad && typeof uad.getHighEntropyValues === 'function') {
|
||
const hv = await uad.getHighEntropyValues(['architecture']);
|
||
if (hv && hv.architecture) return hv.architecture === 'arm' ? 'arm64' : 'x64';
|
||
}
|
||
} catch (e) {}
|
||
try {
|
||
const cvs = document.createElement('canvas');
|
||
const gl = cvs.getContext('webgl') || cvs.getContext('experimental-webgl');
|
||
if (gl) {
|
||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
||
const renderer = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
|
||
if (/apple/i.test(renderer) && !/intel|amd|radeon|nvidia/i.test(renderer)) return 'arm64';
|
||
if (/intel|amd|radeon|nvidia/i.test(renderer)) return 'x64';
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
};
|
||
|
||
// Only label + deep-link when we can name a concrete installer:
|
||
// Windows, Apple Silicon, or Intel Mac. Anything we can't positively
|
||
// identify (undetermined arch, Linux, unknown) shows NO chip and keeps
|
||
// the button's releases-page href — no guessing, no wrong binary.
|
||
if (isWin) {
|
||
apply('Windows', (n) => /win-x64-setup\.exe$/i.test(n));
|
||
} else if (isMac) {
|
||
detectMacArch().then((arch) => {
|
||
if (arch === 'arm64') apply('Apple Silicon', (n) => /mac-arm64\.dmg$/i.test(n));
|
||
else if (arch === 'x64') apply('Intel', (n) => /mac-x64\.dmg$/i.test(n));
|
||
// undetermined → no chip, keep the releases-page fallback
|
||
});
|
||
}
|
||
};
|
||
|
||
// Labs artifact switcher. The painting stays as the `.lab-stage`
|
||
// background; each dock tile floats its artifact (`data-preview-src`) as
|
||
// a window-card centred over it. The "图片" tile maps to the painting
|
||
// itself, so it just hides the card and shows the bare painting.
|
||
const enhanceLabSwitch = () => {
|
||
const stage = document.querySelector('.lab-stage');
|
||
const dock = document.querySelector('[data-lab-dock]');
|
||
const artifact = document.querySelector('[data-lab-artifact]');
|
||
const video = document.querySelector('[data-lab-video]');
|
||
if (!stage || !dock || !artifact) return;
|
||
const items = Array.from(dock.querySelectorAll('[data-dock-item]'));
|
||
if (items.length === 0) return;
|
||
const previewTitle = document.querySelector('[data-lab-preview-title]');
|
||
// The painting is the background; a tile pointing at it means "no
|
||
// floating card" (the bare-painting "图片" view).
|
||
const isBare = (src) => !src || src.indexOf('lab-stage-art') !== -1;
|
||
|
||
// Preload the floating artifacts so the first switch never flashes.
|
||
for (const it of items) {
|
||
const src = it.getAttribute('data-preview-src');
|
||
if (!isBare(src)) {
|
||
const pre = new Image();
|
||
pre.src = src;
|
||
}
|
||
}
|
||
|
||
const show = (it) => {
|
||
const src = it.getAttribute('data-preview-src');
|
||
const vsrc = it.getAttribute('data-preview-video');
|
||
items.forEach((x) => x.classList.toggle('active', x === it));
|
||
const title = it.getAttribute('data-preview-title');
|
||
if (previewTitle && title) previewTitle.textContent = title;
|
||
// Video mode: hide the still card, swap the source if the tile points
|
||
// at a different clip, then show + restart the looping video.
|
||
if (vsrc && video) {
|
||
artifact.classList.remove('is-visible');
|
||
const clip = labClipSrc(vsrc);
|
||
if (video.getAttribute('src') !== clip) video.setAttribute('src', clip);
|
||
video.classList.add('is-visible');
|
||
try {
|
||
video.currentTime = 0;
|
||
const pr = video.play();
|
||
if (pr && pr.catch) pr.catch(() => {});
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
// Any non-video mode: make sure the clip is hidden and paused.
|
||
if (video) {
|
||
video.classList.remove('is-visible');
|
||
try {
|
||
video.pause();
|
||
} catch (_) {}
|
||
}
|
||
if (isBare(src)) {
|
||
artifact.classList.remove('is-visible');
|
||
return;
|
||
}
|
||
// 16:9 stills (data-preview-wide) drop the 1592×1000 box and render at
|
||
// natural width like the video, so wide art isn't cropped.
|
||
artifact.classList.toggle('is-wide', it.hasAttribute('data-preview-wide'));
|
||
artifact.setAttribute('src', src);
|
||
artifact.classList.add('is-visible');
|
||
};
|
||
|
||
// Auto-play: cycle the tiles 1 → 6, 10s each. A click jumps to that
|
||
// tile and restarts the countdown; hovering the stage pauses so a
|
||
// viewer can dwell. Disabled under reduced-motion (clicks still work).
|
||
const INTERVAL = 10000;
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
let idx = 0;
|
||
let timer = null;
|
||
const stop = () => {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
const start = () => {
|
||
if (reduceMotion) return;
|
||
stop();
|
||
timer = setInterval(() => {
|
||
idx = (idx + 1) % items.length;
|
||
show(items[idx]);
|
||
}, INTERVAL);
|
||
};
|
||
|
||
for (const it of items) {
|
||
it.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
idx = items.indexOf(it);
|
||
show(it);
|
||
start();
|
||
});
|
||
}
|
||
|
||
// Show the first tile straight away (static), but only START cycling
|
||
// once the module scrolls into view — and restart from the first tile
|
||
// each time it (re)enters. Hovering still pauses; leaving resumes.
|
||
show(items[0]);
|
||
if (!reduceMotion) {
|
||
stage.addEventListener('mouseenter', stop);
|
||
stage.addEventListener('mouseleave', start);
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) {
|
||
idx = 0;
|
||
show(items[0]);
|
||
start();
|
||
} else {
|
||
stop();
|
||
}
|
||
}
|
||
},
|
||
{ threshold: 0.3 },
|
||
);
|
||
io.observe(stage);
|
||
}
|
||
};
|
||
|
||
|
||
// Pointer-draggable decorative marks ([data-drag-icon]). Pure
|
||
// enhancement — drag offset is held in a transform; without JS the
|
||
// mark just stays at its CSS-default position.
|
||
const enhanceDragIcon = () => {
|
||
const icons = document.querySelectorAll('[data-drag-icon]');
|
||
icons.forEach((el) => {
|
||
let tx = 0;
|
||
let ty = 0;
|
||
let sx = 0;
|
||
let sy = 0;
|
||
let dragging = false;
|
||
el.addEventListener('pointerdown', (e) => {
|
||
dragging = true;
|
||
sx = e.clientX;
|
||
sy = e.clientY;
|
||
el.classList.add('is-dragging');
|
||
try { el.setPointerCapture(e.pointerId); } catch (_) {}
|
||
e.preventDefault();
|
||
});
|
||
el.addEventListener('pointermove', (e) => {
|
||
if (!dragging) return;
|
||
el.style.transform =
|
||
'translate(' + (tx + (e.clientX - sx)) + 'px,' + (ty + (e.clientY - sy)) + 'px)';
|
||
});
|
||
const end = (e) => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
tx += e.clientX - sx;
|
||
ty += e.clientY - sy;
|
||
el.classList.remove('is-dragging');
|
||
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
|
||
};
|
||
el.addEventListener('pointerup', end);
|
||
el.addEventListener('pointercancel', end);
|
||
});
|
||
};
|
||
|
||
// Capabilities scrollytelling — a tall track pins the two-column row; the
|
||
// scroll position through the track picks the active step (left) and the
|
||
// matching art (right), one-to-one. Clicking a step scrolls to its slice.
|
||
const enhanceCapScrolly = () => {
|
||
const scrolly = document.querySelector('[data-cap-scrolly]');
|
||
if (!scrolly) return;
|
||
const sticky = scrolly.querySelector('.cap-sticky');
|
||
const steps = Array.from(scrolly.querySelectorAll('[data-cap-step]'));
|
||
const frames = Array.from(scrolly.querySelectorAll('[data-cap-frame]'));
|
||
const n = steps.length;
|
||
if (n === 0) return;
|
||
// n steps -> n-1 slide transitions spread evenly across the scroll.
|
||
const span = Math.max(1, n - 1);
|
||
const range = () => {
|
||
const stickyH = sticky ? sticky.offsetHeight : window.innerHeight;
|
||
return Math.max(1, scrolly.offsetHeight - stickyH);
|
||
};
|
||
// Below 880px the section is no longer pinned (`.cap-sticky` is
|
||
// `position: static`), so `range()` collapses to ~1px and the
|
||
// scroll-driven reel would push every frame to translateY(0),
|
||
// stacking all four images. On mobile we hand control to click +
|
||
// CSS: clear the inline reel transforms so the `.is-active`
|
||
// translateY rule alone decides which frame shows.
|
||
const isMobile = () => window.innerWidth <= 880;
|
||
let activeStep = -1;
|
||
let ticking = false;
|
||
const setActive = (idx) => {
|
||
activeStep = idx;
|
||
steps.forEach((s, k) => s.classList.toggle('is-active', k === idx));
|
||
frames.forEach((f, k) => f.classList.toggle('is-active', k === idx));
|
||
};
|
||
const update = () => {
|
||
ticking = false;
|
||
if (isMobile()) {
|
||
frames.forEach((f) => {
|
||
f.style.transform = '';
|
||
f.style.zIndex = '';
|
||
});
|
||
return;
|
||
}
|
||
const r = range();
|
||
const scrolled = Math.min(Math.max(-scrolly.getBoundingClientRect().top, 0), r);
|
||
// Continuous progress 0..(n-1). The reel position tracks the scroll
|
||
// 1:1 — frame k slides up from 100% to 0 as `p` crosses k — so the
|
||
// motion follows the finger instead of snapping after a long ease,
|
||
// and each card advances at the same rhythm.
|
||
const p = (scrolled / r) * span;
|
||
frames.forEach((f, k) => {
|
||
f.style.zIndex = String(k + 1);
|
||
const ty = Math.min(Math.max(k - p, 0), 1) * 100;
|
||
f.style.transform = `translateY(${ty}%)`;
|
||
});
|
||
const idx = Math.min(Math.max(Math.round(p), 0), n - 1);
|
||
if (idx !== activeStep) setActive(idx);
|
||
};
|
||
const onScroll = () => {
|
||
if (!ticking) {
|
||
ticking = true;
|
||
requestAnimationFrame(update);
|
||
}
|
||
};
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
window.addEventListener('resize', onScroll);
|
||
steps.forEach((s, i) => {
|
||
s.addEventListener('click', () => {
|
||
if (isMobile()) {
|
||
setActive(i);
|
||
return;
|
||
}
|
||
const targetY =
|
||
window.scrollY + scrolly.getBoundingClientRect().top + (i / span) * range();
|
||
window.scrollTo({ top: targetY, behavior: 'smooth' });
|
||
});
|
||
});
|
||
update();
|
||
};
|
||
|
||
// About tabs: scrolling through the pinned `.about-scrolly` advances the
|
||
// active tab (checks radio 1→2→3); clicking a tab still works and
|
||
// smooth-scrolls to that tab's scroll slice so click + scroll stay in
|
||
// sync. Mirrors `enhanceCapScrolly`.
|
||
const enhanceAboutScrolly = () => {
|
||
const scrolly = document.querySelector('[data-about-scrolly]');
|
||
if (!scrolly) return;
|
||
const sticky = scrolly.querySelector('.about-sticky');
|
||
const panels = Array.from(scrolly.querySelectorAll('.about-panel'));
|
||
const radios = [1, 2, 3]
|
||
.map((n) => document.getElementById('about-tab-' + n))
|
||
.filter(Boolean);
|
||
const labels = Array.from(scrolly.querySelectorAll('.about-tab'));
|
||
const n = radios.length;
|
||
if (n === 0 || panels.length === 0) return;
|
||
// Pinned (desktop) → scroll drives the stack 1:1 (跟手). Otherwise the
|
||
// CSS :checked rules handle click-only switching.
|
||
const pinned = () => window.innerWidth > 880;
|
||
// Fraction of the scroll over which the stack completes; the rest is
|
||
// dwell on the last panel before the pin releases.
|
||
const STACK_END = 0.85;
|
||
const range = () => {
|
||
const stickyH = sticky ? sticky.offsetHeight : window.innerHeight;
|
||
return Math.max(1, scrolly.offsetHeight - stickyH);
|
||
};
|
||
let ticking = false;
|
||
const update = () => {
|
||
ticking = false;
|
||
if (!pinned()) {
|
||
// Hand back to CSS (mobile / click-only).
|
||
panels.forEach((p) => {
|
||
p.style.transform = '';
|
||
p.style.transition = '';
|
||
p.style.zIndex = '';
|
||
});
|
||
return;
|
||
}
|
||
// Continuous stacking, no CSS transition so the cover tracks scroll
|
||
// 1:1: the next panel slides UP from below over the current one as
|
||
// you advance (it parks at 100% and rises to 0). The stack finishes
|
||
// by ~85% of the scroll (STACK_END) so the LAST panel is fully up
|
||
// and dwells before the section unpins and the page scrolls on.
|
||
const scrolled = Math.min(
|
||
Math.max(-scrolly.getBoundingClientRect().top, 0),
|
||
range(),
|
||
);
|
||
const progress =
|
||
Math.min(1, scrolled / (range() * STACK_END)) * (n - 1);
|
||
const base = Math.floor(progress);
|
||
const frac = progress - base;
|
||
panels.forEach((p, k) => {
|
||
p.style.transition = 'none';
|
||
p.style.zIndex = String(k + 1);
|
||
let ty;
|
||
if (k <= base) ty = 0;
|
||
else if (k === base + 1) ty = (1 - frac) * 100;
|
||
else ty = 100;
|
||
p.style.transform = 'translateY(' + ty + '%)';
|
||
});
|
||
const idx = Math.min(n - 1, Math.max(0, Math.round(progress)));
|
||
if (!radios[idx].checked) radios[idx].checked = true;
|
||
};
|
||
const onScroll = () => {
|
||
if (!ticking) {
|
||
ticking = true;
|
||
requestAnimationFrame(update);
|
||
}
|
||
};
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
window.addEventListener('resize', onScroll);
|
||
labels.forEach((label, i) => {
|
||
label.addEventListener('click', () => {
|
||
// Click → scroll the pin to the matching frame only makes sense
|
||
// while the section is pinned (desktop). On mobile there's no pin
|
||
// and the CSS :checked rules switch panels in place, so scrolling
|
||
// here would just shove the page up/down for no reason.
|
||
if (!pinned()) return;
|
||
const frac = n > 1 ? (i / (n - 1)) * STACK_END : 0;
|
||
const targetY =
|
||
window.scrollY +
|
||
scrolly.getBoundingClientRect().top +
|
||
frac * range();
|
||
window.scrollTo({ top: targetY, behavior: 'smooth' });
|
||
});
|
||
});
|
||
update();
|
||
};
|
||
|
||
// FAQ: hover to expand. Opens the <details> on pointer-enter and closes
|
||
// it on leave, so an answer reveals while hovering its row. Native click
|
||
// / touch / keyboard still toggle, so it stays usable without a pointer.
|
||
const enhanceFaqHover = () => {
|
||
for (const li of document.querySelectorAll('.faq-item')) {
|
||
const d = li.querySelector('details');
|
||
if (!d) continue;
|
||
li.addEventListener('mouseenter', () => {
|
||
d.open = true;
|
||
});
|
||
li.addEventListener('mouseleave', () => {
|
||
d.open = false;
|
||
});
|
||
}
|
||
};
|
||
|
||
// Newsletter: no mailing-list backend exists yet, so intercept submit
|
||
// and swap the form for its localized thanks line (data-newsletter-done,
|
||
// from home-translations). Replace this with a real provider POST once
|
||
// a subscribe endpoint lands.
|
||
const enhanceNewsletter = () => {
|
||
for (const form of document.querySelectorAll('form[data-newsletter]')) {
|
||
form.addEventListener('submit', (event) => {
|
||
event.preventDefault();
|
||
if (!form.reportValidity()) return;
|
||
const input = form.querySelector('input[type="email"], input[name="email"]');
|
||
const email = input && input.value ? input.value.trim() : '';
|
||
if (!email) return;
|
||
const submit = form.querySelector('[type="submit"]');
|
||
// Drop any prior inline error, lock the button while in flight.
|
||
const priorErr = form.querySelector('.newsletter-error');
|
||
if (priorErr) priorErr.remove();
|
||
if (submit) submit.disabled = true;
|
||
|
||
const showDone = () => {
|
||
const done = document.createElement('p');
|
||
done.className = 'newsletter-done';
|
||
done.textContent = form.dataset.newsletterDone || 'Thanks!';
|
||
form.replaceChildren(done);
|
||
};
|
||
const showError = () => {
|
||
if (submit) submit.disabled = false;
|
||
if (form.querySelector('.newsletter-error')) return;
|
||
const err = document.createElement('p');
|
||
err.className = 'newsletter-error';
|
||
err.setAttribute('role', 'alert');
|
||
err.textContent =
|
||
form.dataset.newsletterError || 'Couldn’t subscribe just now — please try again.';
|
||
form.appendChild(err);
|
||
};
|
||
|
||
// Real subscription: POST the email to the Cloudflare Pages
|
||
// Function at /subscribe (writes the Resend contact + KV backup).
|
||
// Success → thanks line; failure → inline error so the visitor
|
||
// knows it didn't go through (no silent fake-success).
|
||
fetch('/subscribe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, source: 'landing' }),
|
||
})
|
||
.then((r) => {
|
||
if (r.ok) showDone();
|
||
else showError();
|
||
})
|
||
.catch(showError);
|
||
});
|
||
}
|
||
};
|
||
|
||
const elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
|
||
enhanceHeader();
|
||
// Mobile hamburger toggle. `header-enhancer.astro` binds this on every
|
||
// sub-page (via sub-page-layout), but the homepage doesn't include it,
|
||
// so the toggle was inert here — wire the same behaviour inline.
|
||
(function enhanceMobileNav() {
|
||
const navToggle = document.querySelector('[data-nav-toggle]');
|
||
const primaryNav = document.querySelector('[data-nav-primary]');
|
||
const navHost = navToggle ? navToggle.closest('header.nav') : null;
|
||
if (!navToggle || !primaryNav || !navHost) return;
|
||
const setNavOpen = (open) => {
|
||
navHost.classList.toggle('is-open', open);
|
||
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
};
|
||
navToggle.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
setNavOpen(!navHost.classList.contains('is-open'));
|
||
});
|
||
primaryNav.querySelectorAll('a').forEach((link) => {
|
||
link.addEventListener('click', () => setNavOpen(false));
|
||
});
|
||
document.addEventListener('click', (ev) => {
|
||
if (!navHost.contains(ev.target)) setNavOpen(false);
|
||
});
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape') setNavOpen(false);
|
||
});
|
||
})();
|
||
// Open Design Cloud (AMR) header account. Same as `enhanceMobileNav`
|
||
// above, this mirrors `header-enhancer.astro` because the homepage does
|
||
// not include that enhancer. Detect a live cloud session, swap the
|
||
// "Sign in" link for the avatar menu, drive popup login + polling, and
|
||
// sign out. Everything fails closed to signed-out (non-apex origin or
|
||
// blocked third-party cookies just keep "Sign in" showing).
|
||
(function enhanceAmrAccount() {
|
||
const account = document.querySelector('[data-amr-account]');
|
||
if (!account) return;
|
||
const api = (account.getAttribute('data-amr-api') || '').replace(/\/$/, '');
|
||
const loginUrl = account.getAttribute('data-amr-login') || '';
|
||
// Localized OD homepage — post-login destination ("回到官网首页").
|
||
const homeUrl = account.getAttribute('data-amr-home') || '';
|
||
const signinEl = account.querySelector('[data-amr-signin]');
|
||
const menuEl = account.querySelector('[data-amr-menu]');
|
||
const avatarImg = account.querySelector('[data-amr-avatar]');
|
||
const avatarFallback = account.querySelector('[data-amr-avatar-fallback]');
|
||
const nameEl = account.querySelector('[data-amr-name]');
|
||
const emailEl = account.querySelector('[data-amr-email]');
|
||
const signoutBtn = account.querySelector('[data-amr-signout]');
|
||
|
||
const sessionUrl = api + '/api/auth/get-session';
|
||
const signoutUrl = api + '/api/auth/sign-out';
|
||
|
||
const fetchSession = () =>
|
||
fetch(sessionUrl, {
|
||
credentials: 'include',
|
||
headers: { Accept: 'application/json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((data) => (data && data.user ? data.user : null))
|
||
.catch(() => null);
|
||
|
||
const initials = (user) => {
|
||
const base = (user.name || user.email || '').trim();
|
||
return base ? base[0].toUpperCase() : '·';
|
||
};
|
||
|
||
const showSignedIn = (user) => {
|
||
if (nameEl) nameEl.textContent = user.name || '';
|
||
if (emailEl) emailEl.textContent = user.email || '';
|
||
if (avatarImg && user.image) {
|
||
avatarImg.setAttribute('src', user.image);
|
||
avatarImg.removeAttribute('hidden');
|
||
if (avatarFallback) avatarFallback.setAttribute('hidden', '');
|
||
} else {
|
||
if (avatarImg) avatarImg.setAttribute('hidden', '');
|
||
if (avatarFallback) {
|
||
avatarFallback.textContent = initials(user);
|
||
avatarFallback.removeAttribute('hidden');
|
||
}
|
||
}
|
||
if (signinEl) signinEl.setAttribute('hidden', '');
|
||
if (menuEl) menuEl.removeAttribute('hidden');
|
||
};
|
||
|
||
const showSignedOut = () => {
|
||
if (menuEl) {
|
||
menuEl.setAttribute('hidden', '');
|
||
menuEl.removeAttribute('open');
|
||
}
|
||
if (signinEl) signinEl.removeAttribute('hidden');
|
||
};
|
||
|
||
// Focus scrim shown while the vela login popup is open (popup is the
|
||
// chosen modal mechanism; iframe is ruled out by X-Frame-Options +
|
||
// cross-site cookies + OAuth-in-iframe). Faded via `.is-active`.
|
||
let overlayEl = null;
|
||
const showOverlay = () => {
|
||
if (overlayEl) return;
|
||
overlayEl = document.createElement('div');
|
||
overlayEl.className = 'nav-login-overlay';
|
||
overlayEl.setAttribute('aria-hidden', 'true');
|
||
document.body.appendChild(overlayEl);
|
||
requestAnimationFrame(() => {
|
||
if (overlayEl) overlayEl.classList.add('is-active');
|
||
});
|
||
};
|
||
const hideOverlay = () => {
|
||
if (!overlayEl) return;
|
||
const el = overlayEl;
|
||
overlayEl = null;
|
||
el.classList.remove('is-active');
|
||
const drop = () => el.remove();
|
||
el.addEventListener('transitionend', drop, { once: true });
|
||
setTimeout(drop, 400);
|
||
};
|
||
|
||
// Return the main window to the localized OD homepage after login;
|
||
// a homepage sign-in just swaps in place (already on it).
|
||
const goHome = () => {
|
||
if (!homeUrl) return;
|
||
const targetUrl = new URL(homeUrl, window.location.href);
|
||
const here = window.location.pathname.replace(/\/+$/, '');
|
||
const target = targetUrl.pathname.replace(/\/+$/, '');
|
||
if (here !== target) window.location.assign(homeUrl);
|
||
};
|
||
|
||
// Single terminal success path for every login completion route.
|
||
const completeSignIn = (user) => {
|
||
hideOverlay();
|
||
showSignedIn(user);
|
||
goHome();
|
||
};
|
||
|
||
// Initial silent probe for an existing session.
|
||
fetchSession().then((user) => {
|
||
if (user) showSignedIn(user);
|
||
});
|
||
|
||
if (signinEl && loginUrl) {
|
||
signinEl.addEventListener('click', (ev) => {
|
||
ev.preventDefault();
|
||
// Center the popup over the current browser window (multi-monitor
|
||
// aware via screenLeft/screenTop).
|
||
const W = 480;
|
||
const H = 720;
|
||
const dx = window.screenLeft ?? window.screenX ?? 0;
|
||
const dy = window.screenTop ?? window.screenY ?? 0;
|
||
const vw = window.innerWidth || document.documentElement.clientWidth || screen.width;
|
||
const vh = window.innerHeight || document.documentElement.clientHeight || screen.height;
|
||
const left = Math.round(dx + (vw - W) / 2);
|
||
const top = Math.round(dy + (vh - H) / 2);
|
||
const popup = window.open(
|
||
loginUrl,
|
||
'od-amr-login',
|
||
`width=${W},height=${H},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
|
||
);
|
||
// Popup blocked: fall back to a same-tab navigation.
|
||
if (!popup) {
|
||
window.location.assign(loginUrl);
|
||
return;
|
||
}
|
||
showOverlay();
|
||
const started = Date.now();
|
||
const POLL_MS = 2000;
|
||
const MAX_MS = 5 * 60 * 1000;
|
||
const timer = setInterval(() => {
|
||
if (Date.now() - started > MAX_MS) {
|
||
clearInterval(timer);
|
||
hideOverlay();
|
||
return;
|
||
}
|
||
if (popup.closed) {
|
||
clearInterval(timer);
|
||
fetchSession().then((user) => {
|
||
if (user) completeSignIn(user);
|
||
else hideOverlay();
|
||
});
|
||
return;
|
||
}
|
||
fetchSession().then((user) => {
|
||
if (!user) return;
|
||
clearInterval(timer);
|
||
try {
|
||
if (!popup.closed) popup.close();
|
||
} catch {}
|
||
completeSignIn(user);
|
||
});
|
||
}, POLL_MS);
|
||
});
|
||
}
|
||
|
||
if (signoutBtn) {
|
||
signoutBtn.addEventListener('click', () => {
|
||
fetch(signoutUrl, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { Accept: 'application/json' },
|
||
})
|
||
.catch(() => {})
|
||
.then(() => showSignedOut());
|
||
});
|
||
}
|
||
})();
|
||
// Product and Resources are category-label <button class="nav-trigger">
|
||
// triggers (not links, so they never navigate) whose dropdown is
|
||
// revealed by the same pure-CSS :hover / :focus-within rule as the hub
|
||
// menus — no JS needed (works on first paint / JS-disabled, and on
|
||
// touch via focus). See header-enhancer.astro.
|
||
enhanceDragIcon();
|
||
enhanceWire();
|
||
enhanceStatementReveal();
|
||
enhanceLabDock();
|
||
enhanceLabAutoCycle();
|
||
enhanceCountups();
|
||
enhanceDownloadCta();
|
||
// Deferred: its init preloads ~500KB of Labs preview stills. The dock
|
||
// is far below the fold, so hold that burst until it nears the viewport.
|
||
window.__whenNear('.lab-stage', enhanceLabSwitch);
|
||
enhanceCapScrolly();
|
||
enhanceAboutScrolly();
|
||
enhanceFaqHover();
|
||
enhanceNewsletter();
|
||
if (elements.length === 0) return;
|
||
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
if (reduceMotion || !('IntersectionObserver' in window)) {
|
||
for (const el of elements) el.dataset.revealed = 'true';
|
||
return;
|
||
}
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue;
|
||
entry.target.dataset.revealed = 'true';
|
||
observer.unobserve(entry.target);
|
||
}
|
||
},
|
||
{ threshold: 0.12, rootMargin: '0px 0px -8% 0px' },
|
||
);
|
||
|
||
for (const el of elements) observer.observe(el);
|
||
})();
|
||
</script>
|
||
<script is:inline>
|
||
const initContributorGlobes = () => {
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const canvases = Array.from(
|
||
document.querySelectorAll('[data-testimonial-globe] canvas'),
|
||
);
|
||
if (canvases.length === 0) return;
|
||
|
||
const markers = [
|
||
{ location: [37.7749, -122.4194], size: 0.055 },
|
||
{ location: [40.7128, -74.006], size: 0.045 },
|
||
{ location: [52.52, 13.405], size: 0.052 },
|
||
{ location: [35.6762, 139.6503], size: 0.05 },
|
||
{ location: [31.2304, 121.4737], size: 0.045 },
|
||
{ location: [1.3521, 103.8198], size: 0.04 },
|
||
{ location: [-23.5505, -46.6333], size: 0.042 },
|
||
{ location: [-33.8688, 151.2093], size: 0.042 },
|
||
];
|
||
|
||
for (const canvas of canvases) {
|
||
if (canvas.dataset.globeReady === 'true') continue;
|
||
canvas.dataset.globeReady = 'true';
|
||
|
||
let width = 0;
|
||
let phi = 0.32;
|
||
let frameId = 0;
|
||
let active = false;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
const updateSize = () => {
|
||
width = Math.max(260, Math.floor(canvas.offsetWidth || 360));
|
||
};
|
||
updateSize();
|
||
window.addEventListener('resize', updateSize, { passive: true });
|
||
|
||
const globe = window.__cobe(canvas, {
|
||
devicePixelRatio: dpr,
|
||
width: width * dpr,
|
||
height: width * dpr,
|
||
phi,
|
||
theta: 0.28,
|
||
dark: 0,
|
||
diffuse: 0,
|
||
mapSamples: 12000,
|
||
mapBrightness: 1.0,
|
||
mapBaseBrightness: 0,
|
||
baseColor: [1, 1, 1],
|
||
markerColor: [0.31, 0.98, 0.08],
|
||
glowColor: [1, 1, 1],
|
||
opacity: 1,
|
||
scale: 1,
|
||
markerElevation: 0.015,
|
||
markers: markers.map((marker) => ({
|
||
location: [...marker.location],
|
||
size: marker.size,
|
||
})),
|
||
});
|
||
|
||
const render = () => {
|
||
if (!active) return;
|
||
if (!reduceMotion) phi += 0.0042;
|
||
globe.update({
|
||
width: width * dpr,
|
||
height: width * dpr,
|
||
phi,
|
||
});
|
||
frameId = requestAnimationFrame(render);
|
||
};
|
||
const start = () => {
|
||
if (active) return;
|
||
active = true;
|
||
frameId = requestAnimationFrame(render);
|
||
};
|
||
const stop = () => {
|
||
active = false;
|
||
if (frameId) cancelAnimationFrame(frameId);
|
||
frameId = 0;
|
||
};
|
||
|
||
if ('IntersectionObserver' in window) {
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => {
|
||
if (entry?.isIntersecting) start();
|
||
else stop();
|
||
},
|
||
{ threshold: 0.05 },
|
||
);
|
||
observer.observe(canvas);
|
||
} else {
|
||
start();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Load the cobe runtime and build the globes only once the testimonial
|
||
// section approaches the viewport (~1.5 screens early, so the WebGL globe
|
||
// is ready before it scrolls in). `enhanceContributorOrbit()` below does
|
||
// not depend on cobe and still runs immediately.
|
||
(() => {
|
||
const canvases = document.querySelectorAll('[data-testimonial-globe] canvas');
|
||
if (canvases.length === 0) return;
|
||
const load = () =>
|
||
window
|
||
.__loadEnhancer(window.__enhancerUrls.cobe)
|
||
.then(initContributorGlobes)
|
||
.catch(() => {});
|
||
if (!('IntersectionObserver' in window)) {
|
||
load();
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
io.disconnect();
|
||
load();
|
||
}
|
||
},
|
||
{ rootMargin: '0px 0px 150% 0px' },
|
||
);
|
||
canvases.forEach((canvas) => io.observe(canvas));
|
||
})();
|
||
|
||
// Contributor avatars orbiting the globe. Fetches the same GitHub
|
||
// contributors list and lays the avatars out at even angles around a
|
||
// spinning ring (see `.contributor-orbit` in globals.css). No spokes.
|
||
const enhanceContributorOrbit = () => {
|
||
const mounts = Array.from(
|
||
document.querySelectorAll('[data-contributor-orbit]'),
|
||
);
|
||
if (mounts.length === 0) return;
|
||
|
||
const build = (avatars) => {
|
||
const count = avatars.length;
|
||
for (const mount of mounts) {
|
||
const ring = document.createElement('div');
|
||
ring.className = 'orbit-ring';
|
||
avatars.forEach((a, i) => {
|
||
const slot = document.createElement('div');
|
||
slot.className = 'orbit-slot';
|
||
slot.style.setProperty('--angle', `${(360 / count) * i}deg`);
|
||
|
||
const avatar = document.createElement('a');
|
||
avatar.className = 'orbit-avatar';
|
||
avatar.href = a.href;
|
||
avatar.target = '_blank';
|
||
avatar.rel = 'noreferrer noopener';
|
||
avatar.setAttribute('aria-label', `Open ${a.handle} on GitHub`);
|
||
|
||
const img = document.createElement('img');
|
||
img.src = a.src;
|
||
img.alt = '';
|
||
// Eager + no-referrer: these avatars come from
|
||
// avatars.githubusercontent.com, which is slow / flaky from some
|
||
// regions (notably mainland China). Lazy-loading them meant the
|
||
// ring looked empty for the first few seconds (or indefinitely on
|
||
// a dropped request). Eager-load as soon as the ring is built —
|
||
// well before the user scrolls the testimonial into view.
|
||
img.loading = 'eager';
|
||
img.decoding = 'async';
|
||
img.referrerPolicy = 'no-referrer';
|
||
// Hard-fail fallback: if the avatar can't load at all, show the
|
||
// contributor's initial on the neutral chip instead of a blank.
|
||
img.addEventListener('error', () => {
|
||
img.remove();
|
||
avatar.classList.add('orbit-avatar--fallback');
|
||
avatar.textContent = (a.handle.charAt(0) || '?').toUpperCase();
|
||
});
|
||
|
||
avatar.append(img);
|
||
slot.append(avatar);
|
||
ring.append(slot);
|
||
});
|
||
mount.replaceChildren(ring);
|
||
}
|
||
};
|
||
|
||
// Live GitHub API fallback — only used if the vendored manifest is
|
||
// missing (e.g. `pnpm vendor:contributors` hasn't run). These avatars
|
||
// come from avatars.githubusercontent.com and can be slow/flaky.
|
||
const buildFromGitHub = () => {
|
||
fetch('https://api.github.com/repos/nexu-io/open-design/contributors?per_page=60', {
|
||
headers: { Accept: 'application/vnd.github+json' },
|
||
})
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||
.then((data) => {
|
||
if (!Array.isArray(data)) return;
|
||
const avatars = data
|
||
.filter(
|
||
(c) =>
|
||
c &&
|
||
typeof c.login === 'string' &&
|
||
typeof c.avatar_url === 'string' &&
|
||
typeof c.html_url === 'string' &&
|
||
c.type !== 'Bot' &&
|
||
!c.login.endsWith('[bot]'),
|
||
)
|
||
.slice(0, 16)
|
||
.map((c) => ({
|
||
src: `${c.avatar_url}${c.avatar_url.includes('?') ? '&' : '?'}s=96`,
|
||
href: c.html_url,
|
||
handle: c.login,
|
||
}));
|
||
if (avatars.length === 0) return;
|
||
build(avatars);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
// Prefer locally vendored avatars (public/contributors/manifest.json):
|
||
// served from our own origin, so they load instantly and reliably with
|
||
// no cross-border request. Each entry is already { src, href, handle }.
|
||
fetch('/contributors/manifest.json')
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('no manifest'))))
|
||
.then((data) => {
|
||
if (!Array.isArray(data) || data.length === 0) {
|
||
buildFromGitHub();
|
||
return;
|
||
}
|
||
build(data.slice(0, 16));
|
||
})
|
||
.catch(() => buildFromGitHub());
|
||
};
|
||
|
||
// Deferred: builds ~350KB of contributor avatars. The orbit sits well
|
||
// below the fold, so hold that burst until it nears the viewport.
|
||
window.__whenNear('[data-contributor-orbit]', enhanceContributorOrbit);
|
||
</script>
|
||
<script is:inline>
|
||
// FallingText (React Bits) ported to vanilla matter-js. The agent-name
|
||
// chips in the Method section drop into a physics playground on trigger;
|
||
// the words are server-rendered (.falling-word) so the text is readable
|
||
// and SEO-visible before any physics runs. matter-js is vendored to
|
||
// `public/enhancers/matter.min.js` and injected on demand by the scroll
|
||
// gate below (see the shared loader), attaching `Matter` to `window`.
|
||
|
||
const initFallingText = () => {
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const containers = Array.from(
|
||
document.querySelectorAll('[data-falling-text]'),
|
||
);
|
||
if (containers.length === 0) return;
|
||
|
||
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint, Body } = window.Matter;
|
||
|
||
for (const container of containers) {
|
||
if (container.dataset.fallingReady === 'true') continue;
|
||
const target = container.querySelector('.falling-text-target');
|
||
const canvasContainer = container.querySelector('.falling-text-canvas');
|
||
if (!target || !canvasContainer) continue;
|
||
|
||
// The reveal banner is draggable: the user can grab it with the mouse
|
||
// and move it anywhere in the section. We keep the centring translate
|
||
// and layer the drag delta on top via `--drag-x` / `--drag-y`, and
|
||
// stop the pointer event from reaching the matter.js mouse so the
|
||
// physics chips behind it don't get grabbed at the same time.
|
||
const revealEl = container.querySelector('[data-falling-reveal]');
|
||
if (revealEl) {
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let baseX = 0;
|
||
let baseY = 0;
|
||
const readVar = (name) =>
|
||
Number.parseFloat(revealEl.style.getPropertyValue(name)) || 0;
|
||
revealEl.addEventListener('pointerdown', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
dragging = true;
|
||
startX = event.clientX;
|
||
startY = event.clientY;
|
||
baseX = readVar('--drag-x');
|
||
baseY = readVar('--drag-y');
|
||
revealEl.classList.add('is-dragging');
|
||
revealEl.setPointerCapture(event.pointerId);
|
||
});
|
||
revealEl.addEventListener('pointermove', (event) => {
|
||
if (!dragging) return;
|
||
revealEl.style.setProperty('--drag-x', `${baseX + (event.clientX - startX)}px`);
|
||
revealEl.style.setProperty('--drag-y', `${baseY + (event.clientY - startY)}px`);
|
||
});
|
||
const endDrag = (event) => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
revealEl.classList.remove('is-dragging');
|
||
try {
|
||
revealEl.releasePointerCapture(event.pointerId);
|
||
} catch {
|
||
/* pointer already released */
|
||
}
|
||
};
|
||
revealEl.addEventListener('pointerup', endDrag);
|
||
revealEl.addEventListener('pointercancel', endDrag);
|
||
}
|
||
|
||
const gravity = Number.parseFloat(container.dataset.gravity || '1');
|
||
const stiffness = Number.parseFloat(container.dataset.stiffness || '0.2');
|
||
const trigger = container.dataset.trigger || 'hover';
|
||
|
||
let started = false;
|
||
const runPhysics = () => {
|
||
const rect = container.getBoundingClientRect();
|
||
const width = rect.width;
|
||
const height = rect.height;
|
||
if (width <= 0 || height <= 0) return;
|
||
|
||
const engine = Engine.create();
|
||
engine.world.gravity.y = gravity;
|
||
|
||
const render = Render.create({
|
||
element: canvasContainer,
|
||
engine,
|
||
options: { width, height, background: 'transparent', wireframes: false },
|
||
});
|
||
|
||
const wall = { isStatic: true, render: { fillStyle: 'transparent' } };
|
||
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, wall);
|
||
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, wall);
|
||
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, wall);
|
||
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, wall);
|
||
|
||
const words = Array.from(target.querySelectorAll('.falling-word'));
|
||
const bodies = words.map((elem) => {
|
||
const r = elem.getBoundingClientRect();
|
||
const x = r.left - rect.left + r.width / 2;
|
||
const y = r.top - rect.top + r.height / 2;
|
||
const body = Bodies.rectangle(x, y, r.width, r.height, {
|
||
restitution: 0.8,
|
||
frictionAir: 0.01,
|
||
friction: 0.2,
|
||
render: { fillStyle: 'transparent' },
|
||
});
|
||
Body.setVelocity(body, { x: (Math.random() - 0.5) * 5, y: 0 });
|
||
Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
|
||
return { elem, body };
|
||
});
|
||
|
||
for (const { elem } of bodies) {
|
||
elem.style.position = 'absolute';
|
||
elem.style.margin = '0';
|
||
}
|
||
|
||
const mouse = Mouse.create(container);
|
||
// Don't let the physics surface swallow page scroll over the canvas.
|
||
const m = mouse;
|
||
m.element.removeEventListener('wheel', m.mousewheel);
|
||
m.element.removeEventListener('DOMMouseScroll', m.mousewheel);
|
||
const mouseConstraint = MouseConstraint.create(engine, {
|
||
mouse,
|
||
constraint: { stiffness, render: { visible: false } },
|
||
});
|
||
render.mouse = mouse;
|
||
|
||
World.add(engine.world, [
|
||
floor,
|
||
leftWall,
|
||
rightWall,
|
||
ceiling,
|
||
mouseConstraint,
|
||
...bodies.map((b) => b.body),
|
||
]);
|
||
|
||
const runner = Runner.create();
|
||
Runner.run(runner, engine);
|
||
Render.run(render);
|
||
|
||
// Banner appears the moment the icons start dropping — no waiting
|
||
// for the pile to settle.
|
||
container
|
||
.querySelector('[data-falling-reveal]')
|
||
?.classList.add('is-visible');
|
||
|
||
const loop = () => {
|
||
for (const { body, elem } of bodies) {
|
||
elem.style.left = `${body.position.x}px`;
|
||
elem.style.top = `${body.position.y}px`;
|
||
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
|
||
}
|
||
requestAnimationFrame(loop);
|
||
};
|
||
loop();
|
||
};
|
||
|
||
const start = () => {
|
||
if (started) return;
|
||
started = true;
|
||
container.dataset.fallingReady = 'true';
|
||
runPhysics();
|
||
};
|
||
|
||
if (reduceMotion) {
|
||
container.dataset.fallingReady = 'true';
|
||
// No physics for reduced-motion readers — just show the banner.
|
||
container.querySelector('[data-falling-reveal]')?.classList.add('is-visible');
|
||
continue;
|
||
}
|
||
if (trigger === 'auto') start();
|
||
else if (trigger === 'scroll') {
|
||
const io = new IntersectionObserver(
|
||
([entry]) => {
|
||
if (entry?.isIntersecting) {
|
||
start();
|
||
io.disconnect();
|
||
}
|
||
},
|
||
// Fire once the icon row has scrolled up into roughly the top
|
||
// half of the viewport ("near the top"), not the moment it peeks
|
||
// in from the bottom.
|
||
{ threshold: 0, rootMargin: '0px 0px -45% 0px' },
|
||
);
|
||
io.observe(container);
|
||
} else if (trigger === 'click') {
|
||
container.addEventListener('click', start, { once: true });
|
||
} else {
|
||
container.addEventListener('mouseenter', start, { once: true });
|
||
}
|
||
}
|
||
};
|
||
|
||
// Reduced-motion readers never get physics, so skip the matter-js
|
||
// download entirely and just reveal the server-rendered banner. Everyone
|
||
// else loads the runtime ~1.5 screens before the Method section scrolls
|
||
// in, so `initFallingText()`'s own per-chip trigger fires without lag.
|
||
(() => {
|
||
const containers = Array.from(document.querySelectorAll('[data-falling-text]'));
|
||
if (containers.length === 0) return;
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
if (reduceMotion) {
|
||
for (const container of containers) {
|
||
container.dataset.fallingReady = 'true';
|
||
container.querySelector('[data-falling-reveal]')?.classList.add('is-visible');
|
||
}
|
||
return;
|
||
}
|
||
const load = () =>
|
||
window
|
||
.__loadEnhancer(window.__enhancerUrls.matter)
|
||
.then(initFallingText)
|
||
.catch(() => {});
|
||
if (!('IntersectionObserver' in window)) {
|
||
load();
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
io.disconnect();
|
||
load();
|
||
}
|
||
},
|
||
{ rootMargin: '0px 0px 150% 0px' },
|
||
);
|
||
containers.forEach((container) => io.observe(container));
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|