Files
nexu-io-open-design/apps/landing-page/app/pages/index.astro
lefarcen 101a42cb0d perf(landing): cut homepage LCP — defer JS + below-fold art, shrink backdrops, early locale redirect (#4987)
* 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.
2026-07-01 06:49:03 +00:00

2163 lines
94 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 || 'Couldnt 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>