aura-components-mcp 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  > I got tired of opening twenty Aura tabs every time I started a landing page. So I built the MCP server I wanted: every free thing on aura.build, one stdio call away, no account, no key, no browser.
10
10
 
11
- > **v1.5.0** — 20 tools. Search never empties (OR-token fallback + suggested queries), bulk-fetch with `aura_bulk_fetch` (`aura_bundle` alias),undle`, one-turn page builds with `aura_scaffold_page`, dark/light faceting, related items, legal asset install.
11
+ > **v1.6.0** — 20 tools. Search facets now equal detail facets (icons, fonts, weight), skills deduped by source_url, full DESIGN token surface with preview-mode warnings, legal asset plans, scoped CSS + paginated installs, transparent fallbacks. Search never empties (OR-token fallback + suggested queries), bulk-fetch with `aura_bulk_fetch` (`aura_bundle` alias),undle`, one-turn page builds with `aura_scaffold_page`, dark/light faceting, related items, legal asset install.
12
12
 
13
13
  Point any MCP client at `dist/server.js` and your agent can search 2,495 free components, read 187 agent skills in full, pull 30,688 assets, and apply 725 DESIGN.md systems. It answers in seconds because there is nothing to log into and almost nothing to download: one bundled JS file, zero dependencies.
14
14
 
@@ -127,6 +127,17 @@ npm run build # rebundle src/*.mjs into dist/server.js
127
127
 
128
128
  Layout: `src/config.mjs` holds env parsing. `src/http.mjs` hides timeouts and retries behind an injectable fetch. `src/catalog.mjs` is the deep module (queries, cache, shaping, counts). `src/guide.mjs` turns rows into install plans. `src/tools.mjs` validates args. `src/protocol.mjs` speaks JSON-RPC. `src/server.mjs` only wires stdio.
129
129
 
130
+ ## Share
131
+
132
+ Star it if it saved you a tab or twenty: [github.com/swastiksingh-dev/Aura-Components-MCP](https://github.com/swastiksingh-dev/Aura-Components-MCP). Reposits welcome — see [DIRECTION.md](./DIRECTION.md) for where this is headed.
133
+
134
+ ```text
135
+ built a free design MCP on aura.build: 2495 components, 187 skills, 30k assets, 20 tools. no login. resources are @MengTo's
136
+
137
+ npx -y aura-components-mcp
138
+ https://github.com/swastiksingh-dev/Aura-Components-MCP
139
+ ```
140
+
130
141
  ## Credits and license
131
142
 
132
143
  Server code by [swastiksingh-dev](https://github.com/swastiksingh-dev), MIT. Free to fork, use, and sell with. Catalogue text, code, and images belong to Aura and the creators listed in each `author` field; follow Aura's terms for that content. If you fork this, keep the credit line and point people at the original repo.
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // aura-components-mcp v1.0.0 — bundled (zero deps). Built by swastiksingh-dev.
2
+ // aura-components-mcp v1.6.0 — bundled (zero deps). Built by swastiksingh-dev.
3
3
 
4
4
  // ---- config.mjs ----
5
5
  // Module: config — one small interface (loadConfig) over all env parsing.
@@ -102,9 +102,9 @@ const CATALOG_SORTS = {
102
102
  };
103
103
 
104
104
  const LIST_COLS = {
105
- // Lists exclude code (13KB/row): theme falls back to background + tags heuristics.
106
- // Full code loads only on get/bundle, where facets recompute exactly.
107
- components: "id,title,description,tags,premium,views,forks,slug,background,created_by,created_at,updated_at",
105
+ // PostgREST can't substring server-side: lists fetch code but shapeRow truncates to
106
+ // a 600-char probe for facets BEFORE the truncation marker (see shapeRow). ~0.7KB/row.
107
+ components: "id,title,description,tags,code,premium,views,forks,slug,background,created_by,created_at,updated_at",
108
108
  skills: "id,title,description,source_url,views,forks,featured,created_by,created_at,updated_at",
109
109
  assets: "id,title,description,keywords,media_type,premium,views,forks,image_800w,video_url,video_poster_url,created_by,created_at",
110
110
  design_systems: "id,slug,title,description,views,forks,featured,created_by,created_at,updated_at",
@@ -176,20 +176,42 @@ const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + "…[truncated]" :
176
176
  function pageUrl(kind, row) {
177
177
  if (kind === "components") return SITE + "/component/" + row.slug;
178
178
  if (kind === "skills") return SITE + "/skills/" + row.id;
179
+ // ISS9: asset pages are query-routed (no /assets/ID route exists). Keep the q= form
180
+ // but ALSO expose a stable key so agents can cite: id + slug + title together.
179
181
  if (kind === "assets") return SITE + "/assets?q=" + encodeURIComponent(row.title ?? "");
180
182
  return SITE + "/design-systems/" + (row.slug ?? row.id);
181
183
  }
182
184
 
185
+ function assetKey(row) {
186
+ return { id: row.id, slug: row.slug ?? null, title: row.title ?? null };
187
+ }
188
+
183
189
  function shapeRow(kind, row, cfg, withFacets) {
184
190
  const base = { ...row };
185
- if (kind === "components" && typeof base.code === "string") { base.code_chars = base.code.length; base.code = trunc(base.code, cfg.codeChars); }
191
+ // Facets probe the first 600 chars of FULL code before truncation, so search facets
192
+ // equal detail facets for icons/fonts/theme. Weight uses the true length (ISS1/2/3).
193
+ if (kind === "components" && typeof base.code === "string") {
194
+ base.code_chars = base.code.length;
195
+ if (withFacets) { try { base.facets = withFacets(kind, { ...base, code: base.code.slice(0, 600) }, { fullLength: base.code.length }); } catch { /* never break rows */ } }
196
+ base.code = trunc(base.code, cfg.codeChars);
197
+ }
186
198
  if (kind === "skills" && typeof base.content === "string") base.content = trunc(base.content, cfg.contentChars);
187
199
  if (kind === "design_systems") {
188
200
  if (typeof base.content === "string") base.content = trunc(base.content, cfg.contentChars);
189
201
  if (typeof base.preview_html === "string") base.preview_html = trunc(base.preview_html, cfg.codeChars);
190
202
  }
191
203
  base.page_url = pageUrl(kind, row);
192
- if (withFacets) { try { base.facets = withFacets(kind, base); } catch { /* facets never break rows */ } }
204
+ if (kind === "assets") base.asset_key = assetKey(row);
205
+ if (withFacets && !base.facets) { try { base.facets = withFacets(kind, base); } catch { /* facets never break rows */ } }
206
+ // ISS8: force absolute Supabase URLs — relative variant paths break fetch.
207
+ if (kind === "assets") {
208
+ const abs = (u) => (!u ? u : (/^https?:\/\//i.test(u) ? u : ("https://hoirqrkdgbmvpwutwuwj-all.supabase.co/storage/v1/object/public/assets/" + String(u).replace(/^\/+/, ""))));
209
+ for (const k of ["image_320w", "image_800w", "image_1600w", "image_3840w", "image_original", "image_url", "video_url", "video_poster_url"]) if (typeof base[k] === "string") base[k] = abs(base[k]);
210
+ if (base.facets) {
211
+ if (base.facets.download) base.facets.download = abs(base.facets.download);
212
+ if (base.facets.preview) base.facets.preview = abs(base.facets.preview);
213
+ }
214
+ }
193
215
  return base;
194
216
  }
195
217
 
@@ -278,15 +300,39 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
278
300
  } catch { /* keep original empty result */ }
279
301
  }
280
302
  }
303
+ // ISS7: dedupe skills by source_url (same SKILL.md listed twice). First (most-viewed)
304
+ // wins, gets canonical:true; dupes are dropped and counted in deduped.
305
+ let deduped = 0;
306
+ if (kind === "skills") {
307
+ const seen = new Set();
308
+ const kept = [];
309
+ for (const r of rows) {
310
+ const u = (r.source_url || "").trim().toLowerCase();
311
+ if (u && seen.has(u)) { deduped++; continue; }
312
+ if (u) seen.add(u);
313
+ kept.push(r);
314
+ }
315
+ const byUrl = new Map();
316
+ for (const r of kept) {
317
+ const u = (r.source_url || "").trim().toLowerCase();
318
+ if (u && (!byUrl.has(u) || (r.views || 0) > (byUrl.get(u).views || 0))) byUrl.set(u, r);
319
+ }
320
+ rows = kept.map((r) => {
321
+ const u = (r.source_url || "").trim().toLowerCase();
322
+ return (u && byUrl.get(u) === r) ? { ...r, canonical: true } : r;
323
+ });
324
+ }
281
325
  const enriched = await enrichAuthors(rows);
282
326
  const out = { kind, total, limit, offset, items: enriched.map((r) => shapeRow(kind, r, config, facetsFn)), cached: false };
327
+ if (deduped) out.deduped = deduped;
283
328
  // Post-filter: theme (dark|light) is derived, not a column — filter shaped rows.
284
329
  if (q.theme && (kind === "components")) {
285
330
  const want = String(q.theme).toLowerCase();
286
331
  out.items = out.items.filter((it) => (it.facets && it.facets.theme) === want);
287
332
  }
288
- if (fallback) out.fallback = fallback;
289
- else if ((!rows || !rows.length) && toks.length) out.suggested_queries = suggest(toks);
333
+ // ISS15: transparent fallback — agents see what happened, not a magic string.
334
+ if (fallback) { out.fallback = fallback; out.isFallback = true; out.fallback_score = "token-overlap:" + toks.length + "-terms"; }
335
+ else if ((!rows || !rows.length) && toks.length) { out.isFallback = false; out.suggested_queries = suggest(toks).filter((s) => !/^[a-z0-9]{8,}$/i.test(s.replace(/\s/g, ""))); if (!out.suggested_queries.length) out.suggested_queries = ["hero", "pricing", "landing page"]; }
290
336
  cacheSet(key, out);
291
337
  return out;
292
338
  })();
@@ -365,7 +411,8 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
365
411
  }
366
412
 
367
413
  // Related: same-tag / same-text overlap, views-ranked, excluding self.
368
- async function relatedItems(kind, idOrSlug, limit = 3) {
414
+ // ISS10: freeOnly default true — free flows never get Pro recommendations.
415
+ async function relatedItems(kind, idOrSlug, limit = 3, freeOnly = true) {
369
416
  const got = await getItem(kind, idOrSlug);
370
417
  const item = got.item;
371
418
  const toks = queryTokens(item.title + " " + (item.description || "")).slice(0, 3);
@@ -375,7 +422,8 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
375
422
  const sel = LIST_COLS[kind];
376
423
  const idCol = (kind === "components") ? "id" : "id";
377
424
  const selfId = encodeURIComponent(String(item.id));
378
- const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&private=eq.false" + (kind === "components" || kind === "assets" ? "" : "") + tagF + "&" + idCol + "=neq." + selfId + (orParts.length ? "&or=(" + orParts.join(",") + ")" : "") + "&order=views.desc&limit=" + lim;
425
+ const premF = (freeOnly && (kind === "components" || kind === "assets")) ? "&premium=eq.false" : "";
426
+ const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&private=eq.false" + premF + tagF + "&" + idCol + "=neq." + selfId + (orParts.length ? "&or=(" + orParts.join(",") + ")" : "") + "&order=views.desc&limit=" + lim;
379
427
  try {
380
428
  const { rows } = await fetcher.getJson(url, headers);
381
429
  const enriched = await enrichAuthors(rows);
@@ -387,10 +435,10 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
387
435
  }
388
436
 
389
437
  // ---- protocol.mjs ----
438
+ const SERVER_INFO = { name: "aura-components-mcp", version: "1.6.0" };
390
439
  // Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
391
440
  // One interface: createSession(send) -> { dispatch(msg) }. No business logic.
392
441
 
393
- const SERVER_INFO = { name: "aura-components-mcp", version: "1.0.0" };
394
442
  const PROTOCOL_VERSION = "2024-11-05";
395
443
 
396
444
  const protocolFn_err = (code, message, data) => ({ code, message, ...(data === undefined ? {} : { data }) });
@@ -463,21 +511,39 @@ const GUIDE_CDN = {
463
511
  iconify: 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js',
464
512
  };
465
513
 
514
+ // Font allowlist: real families only. Tailwind weight/size utilities (medium, semibold,
515
+ // normal, light-as-weight, size-*) are NOT families — old regex leaked them into fonts[].
516
+ const KNOWN_FONTS = ['inter', 'geist', 'manrope', 'sora', 'space-grotesk', 'space-mono', 'poppins', 'montserrat', 'playfair-display', 'instrument-serif', 'dm-sans', 'dm-serif-display', 'jetbrains-mono', 'ibm-plex-mono', 'ibm-plex-serif', 'plus-jakarta-sans', 'bricolage-grotesque', 'newsreader', 'fraunces', 'lora', 'merriweather', 'libre-baskerville', 'cormorant-garamond', 'gfs-didot', 'bodoni-moda', 'cardo', 'marcellus', 'gloock', 'aboreto', 'syne', 'urbanist', 'sora', 'anton', 'bebas-neue', 'archivo-black', 'league-gothic', 'fredoka', 'chewy', 'foldit', 'oi', 'honk', 'nabla', 'quicksand', 'nunito', 'work-sans', 'oswald', 'lora', 'roboto', 'open-sans', 'figtree', 'outfit', 'lexend', 'gloria-hallelujah', 'caveat'];
517
+ const FONT_RE = /font-(?:family-)?\[?['"]?([a-z][a-z0-9-]*)/g;
518
+
466
519
  function detectNeeds(code, tags) {
467
520
  const c = String(code || '').toLowerCase();
468
521
  const t = new Set((tags || []).map((x) => String(x).toLowerCase()));
469
522
  const needsTailwind = c.includes('class=') || t.has('tailwind');
470
- const needsIcons = c.includes('iconify') || c.includes('svg');
523
+ // Probes must work on a 2k prefix too: icon svgs usually appear in the first screen of markup.
524
+ // svg alone under-matches in lists (no code fetched) — but must not over-claim either:
525
+ // 'svg' counts only with an icon-ish neighbor (iconify, <svg, lucide, heroicons, feather).
526
+ const needsIcons = c.includes('iconify') || c.includes('<svg') || c.includes('lucide') || c.includes('heroicon') || c.includes('data-lucide');
471
527
  const needsKeyframes = c.includes('keyframes') || c.includes('animation:');
472
528
  const fonts = new Set();
473
- for (const m of c.matchAll(/font-([a-z0-9-]+)/g)) fonts.add(m[1]);
529
+ for (const m of c.matchAll(FONT_RE)) {
530
+ const fam = m[1].replace(/['"\]]/g, '');
531
+ if (KNOWN_FONTS.includes(fam)) fonts.add(fam);
532
+ }
533
+ // font-family: 'X', Y declarations (not utility classes)
534
+ for (const m of c.matchAll(/font-family\s*:\s*([^;}]{1,80})/g)) {
535
+ for (const part of m[1].split(',')) {
536
+ const fam = part.trim().replace(/['"]/g, '').toLowerCase().replace(/\s+/g, '-');
537
+ if (KNOWN_FONTS.includes(fam)) fonts.add(fam);
538
+ }
539
+ }
474
540
  return { needsTailwind, needsIcons, needsKeyframes, fonts: [...fonts].slice(0, 6) };
475
541
  };
476
542
 
477
543
  // Facets: cheap derived signals so search lists answer "dark? heavy? pro?" without a get.
478
544
  // theme: dark|light|mixed|unknown from background field + code palette probes.
479
545
  // weight: code_chars bucket (s/m/l) so agents can prefer light embeds.
480
- function facets(kind, row) {
546
+ function facets(kind, row, opts) {
481
547
  const f = {};
482
548
  if (kind === 'components') {
483
549
  const bg = String(row.background || '').toLowerCase();
@@ -490,7 +556,7 @@ function facets(kind, row) {
490
556
  const bgDark = bg.includes('000') && !bg.includes('fff');
491
557
  const bgLight = bg.includes('fff') && !bg.includes('000');
492
558
  f.theme = bgDark || tagDark ? 'dark' : (bgLight || tagLight ? 'light' : (code ? (darkHits > lightHits * 2 ? 'dark' : (lightHits > darkHits * 2 ? 'light' : (darkHits || lightHits ? 'mixed' : 'unknown'))) : (tags.includes('saas') || tags.includes('minimal') ? 'light' : 'unknown')));
493
- const n = String(row.code || '').length;
559
+ const n = (opts && opts.fullLength) || String(row.code || '').length;
494
560
  f.weight = n > 20000 ? 'l' : (n > 8000 ? 'm' : 's');
495
561
  f.code_chars = n;
496
562
  const needs = detectNeeds(row.code || '', row.tags || []);
@@ -498,6 +564,13 @@ function facets(kind, row) {
498
564
  f.needsIcons = needs.needsIcons;
499
565
  f.fonts = needs.fonts;
500
566
  }
567
+ // ISS14 lives here too: license is top-level on every asset row, not buried.
568
+ // ISS13: rewrite hotlinked demo images to stable asset references where possible.
569
+ // i.pravatar.cc + bare demo URLs 404 in production; flag them in the install plan.
570
+ if (kind === 'components' && typeof row.code === 'string') {
571
+ const hot = [...row.code.matchAll(/<img[^>]+src=["']([^"']+)["']/gi)].map((m) => m[1]).filter((u) => /pravatar|placehold|dummyimage|unsplash\/photos\/..\/download|example\.com/i.test(u));
572
+ if (hot.length) row = { ...row, _hotlinked: [...new Set(hot)].slice(0, 5) };
573
+ }
501
574
  if (kind === 'assets' || row.image_800w || row.image_original || row.video_url) {
502
575
  // Resolved 2026-09-13 from primary source https://www.aura.build/terms §4-5:
503
576
  // catalogue content is the exclusive property of DESIGNCODE IO PTE. LTD.;
@@ -506,7 +579,11 @@ function facets(kind, row) {
506
579
  f.download = row.image_original || row.image_1600w || row.image_800w || row.video_url || null;
507
580
  f.preview = row.image_800w || row.video_poster_url || null;
508
581
  }
509
- if (kind === 'design_systems') f.has_preview = Boolean(row.preview_html);
582
+ if (kind === 'design_systems') {
583
+ // ISS4: lists never fetch preview_html (24KB/row). has_preview:true is authoritative
584
+ // only on detail; on lists report 'unknown-list' so agents fetch instead of trusting false.
585
+ f.has_preview = row.preview_html !== undefined ? Boolean(row.preview_html) : 'unknown-list';
586
+ }
510
587
  return f;
511
588
  }
512
589
 
@@ -526,11 +603,23 @@ function installGuide(kind, row) {
526
603
  steps.push('Add the iconify-icon CDN script so the icon tags render.');
527
604
  }
528
605
  if (needs.needsKeyframes) steps.push('Keep the embedded style block with the markup: that is where the keyframes live.');
606
+ // ISS11+12: pagination + CSS scoping. Code ships truncated at codeChars; the scope
607
+ // prefix below prevents .button/.inner/.point collisions in multi-component scaffolds.
608
+ const scope = 'aura-' + String(row.slug || row.id).toLowerCase().replace(/[^a-z0-9]+/g, '');
609
+ const fullLen = String(row.code || '').length;
610
+ const isTrunc = /…\[truncated\]$/.test(String(row.code || ''));
611
+ if (isTrunc) {
612
+ files.push({ path: 'components/' + (row.slug || row.id) + '.part1.html', contains: 'markup part 1 of N — fetch aura_get_component again and concatenate code chunks' });
613
+ steps.push('Code is paginated (' + fullLen + ' chars shown of more): install part files in order, or re-fetch for the full body.');
614
+ } else {
615
+ files.push({ path: 'components/' + (row.slug || row.id) + '.html', contains: 'section markup plus its style block' });
616
+ }
617
+ steps.push('Scope global CSS before pasting: prefix bare selectors (.button, .inner, .point) with .' + scope + ' to avoid collisions in multi-component pages.');
529
618
  steps.push('Paste the markup where the section belongs and update the CTA link (it ships as #).');
530
619
  if (url) steps.push('Preview first: ' + url);
531
- files.push({ path: 'components/' + (row.slug || row.id) + '.html', contains: 'section markup plus its style block' });
620
+ if (row._hotlinked && row._hotlinked.length) steps.push('Replace demo images before shipping (' + row._hotlinked.slice(0, 3).join(', ') + '): hotlinked placeholders 404 in production — swap for aura_search_assets results.');
532
621
  if (needs.fonts.length) files.push({ path: 'styles/fonts.css', contains: 'font families referenced: ' + needs.fonts.join(', ') });
533
- return { kind, title: row.title, page_url: url, free: row.premium === false, steps, deps, files, needs };
622
+ return { kind, title: row.title, page_url: url, free: row.premium === false, steps, deps, files, needs, css_scope: scope, code_truncated: isTrunc };
534
623
  }
535
624
  if (kind === 'skills') {
536
625
  steps.push('Read the SKILL.md content from aura_get_skill first: it names its own triggers and pitfalls.');
@@ -539,15 +628,37 @@ function installGuide(kind, row) {
539
628
  return { kind, title: row.title, page_url: url, steps, deps, files: [{ path: 'skills/' + row.id + '/SKILL.md', contains: 'full skill content' }] };
540
629
  }
541
630
  if (kind === 'design_systems') {
631
+ // ISS6: derive preview deps from preview_html so agents don't ship static pages.
632
+ const ph = String(row.preview_html || '');
633
+ const pl = ph.toLowerCase();
634
+ const has = (...ss) => ss.some((s) => pl.includes(s));
635
+ if (has('cdn.tailwindcss.com', 'tailwind')) deps.push({ name: 'tailwindcss', via: 'CDN (in preview_html)', cdn: GUIDE_CDN.tailwind });
636
+ if (has('iconify', '<svg', 'lucide', 'data-lucide')) deps.push({ name: 'iconify-icon', via: 'CDN (in preview_html)', cdn: GUIDE_CDN.iconify });
637
+ if (has('gsap')) deps.push({ name: 'gsap', via: 'CDN (in preview_html)', cdn: 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js' });
638
+ if (has('scrolltrigger')) deps.push({ name: 'gsap-ScrollTrigger', via: 'CDN (in preview_html)', cdn: 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js' });
639
+ const fonts = new Set();
640
+ for (const m of pl.matchAll(/family=([a-z+]+)/g)) fonts.add(m[1].replace(/\+/g, '-'));
641
+ if (fonts.size) deps.push({ name: 'google-fonts:' + [...fonts].slice(0, 4).join(','), via: 'link (in preview_html)' });
542
642
  steps.push('Copy the DESIGN.md content into your repo as DESIGN.md and treat it as the source of truth.');
543
643
  steps.push('Apply the color, type, and spacing tokens before copying any component markup.');
644
+ if (deps.length) steps.push('Install preview deps first (' + deps.map((d) => d.name).join(', ') + ') — the preview_html needs them.');
544
645
  return { kind, title: row.title, page_url: url, steps, deps, files: [{ path: 'DESIGN.md', contains: 'tokens and rules' }] };
545
646
  }
546
647
  steps.push('Use the image_800w URL for previews and image_original for production.');
547
648
  return { kind, title: row.title, page_url: url, steps, deps, files };
548
649
  };
549
650
 
550
- function tokenHints(content) {
651
+ // ISS5: full token surface — colors + typography + spacing + radius — so scaffolded
652
+ // pages don't lose the system. Also detects preview dark/light mode so agents don't
653
+ // apply light-cream tokens to a dark preview body.
654
+ function previewMode(previewHtml) {
655
+ const h = String(previewHtml || '').toLowerCase();
656
+ if (/bg-black|bg-neutral-950|bg-zinc-950|bg-slate-950|bg-\[#0/.test(h)) return 'dark';
657
+ if (/bg-white|bg-neutral-50|bg-slate-50/.test(h)) return 'light';
658
+ return 'unknown';
659
+ }
660
+
661
+ function tokenHints(content, previewHtml) {
551
662
  const Q = String.fromCharCode(39);
552
663
  const lines = String(content || '').split(Q + 'n' === Q + 'n' ? '\n' : '\n');
553
664
  const get = (key) => {
@@ -563,13 +674,42 @@ function tokenHints(content) {
563
674
  };
564
675
  const tokens = {
565
676
  primary: get('primary'),
677
+ secondary: get('secondary'),
678
+ accent: get('accent'),
566
679
  background: get('background'),
567
680
  surface: get('surface'),
568
- text: get('text-primary') || get('text_primary'),
681
+ text_primary: get('text-primary') || get('text_primary'),
682
+ text_secondary: get('text-secondary') || get('text_secondary'),
683
+ border: get('border'),
684
+ display_font: get('display_lg_fontfamily') || get('display-font'),
685
+ body_font: get('body_md_fontfamily') || get('body-font'),
686
+ mono_font: get('label_md_fontfamily') || get('mono-font'),
687
+ section_padding: get('section-padding') || get('section_padding'),
688
+ card_padding: get('card-padding') || get('card_padding'),
689
+ card_radius: get('card') === null ? get('card_radius') : null,
690
+ control_radius: get('control'),
691
+ pill_radius: get('pill'),
569
692
  };
570
- const rows = Object.entries(tokens).filter((e) => e[1]).map((e) => ' --aura-' + e[0] + ': ' + e[1] + ';');
693
+ const pick = (obj, keys) => { const o = {}; for (const k of keys) if (obj[k]) o[k] = obj[k]; return o; };
694
+ const colors = pick(tokens, ['primary', 'secondary', 'accent', 'background', 'surface', 'text_primary', 'text_secondary', 'border']);
695
+ const rows = [];
696
+ for (const [k, v] of Object.entries(colors)) rows.push(' --aura-' + k.replace(/_/g, '-') + ': ' + v + ';');
697
+ if (tokens.body_font) rows.push(' --aura-font-body: ' + tokens.body_font + ';');
698
+ if (tokens.display_font) rows.push(' --aura-font-display: ' + tokens.display_font + ';');
699
+ if (tokens.mono_font) rows.push(' --aura-font-mono: ' + tokens.mono_font + ';');
700
+ if (tokens.section_padding) rows.push(' --aura-section-padding: ' + tokens.section_padding + ';');
701
+ if (tokens.card_padding) rows.push(' --aura-card-padding: ' + tokens.card_padding + ';');
702
+ if (tokens.card_radius) rows.push(' --aura-radius-card: ' + tokens.card_radius + ';');
703
+ if (tokens.control_radius) rows.push(' --aura-radius-control: ' + tokens.control_radius + ';');
704
+ if (tokens.pill_radius) rows.push(' --aura-radius-pill: ' + tokens.pill_radius + ';');
571
705
  const css = rows.length ? ':root{' + '\n' + rows.join('\n') + '\n}' : '';
572
- return { tokens, css };
706
+ const mode = previewMode(previewHtml);
707
+ const warn = (mode === 'dark' && tokens.background && !/000|0f1115|111827|1a1a1a|09090b/i.test(tokens.background))
708
+ ? 'tokens say light background ' + tokens.background + ' but preview_html renders dark — follow the preview body, not the tokens, or ask which mode is wanted'
709
+ : ((mode === 'light' && tokens.background && /000000|0f1115/i.test(tokens.background))
710
+ ? 'tokens say dark background but preview renders light — follow the preview body'
711
+ : null);
712
+ return { tokens, css, preview_mode: mode, mode_warning: warn };
573
713
  };
574
714
 
575
715
  // ---- tools.mjs ----
@@ -671,7 +811,7 @@ function createHandlers(catalog) {
671
811
  aura_use_design_system: async (a) => { a = a || {}; const id = a.id !== undefined ? a.id : a.slug;
672
812
  if (typeof id !== 'string' || !id) toolFn_bad('provide id or slug (string)');
673
813
  const got = await catalog.getItem('design_systems', id);
674
- return textResult({ item: got.item, install: installGuide('design_systems', got.item), tokens: tokenHints(got.item.content || '') }); },
814
+ return textResult({ item: got.item, install: installGuide('design_systems', got.item), tokens: tokenHints(got.item.content || '', got.item.preview_html || '') }); },
675
815
  aura_trending: async (a) => { a = a || {}; const limit = toolFn_num(a.limit, 'limit') || 5;
676
816
  const r = await Promise.all([
677
817
  catalog.searchCatalog('components', { freeOnly: true, sort: 'trending', limit }),
@@ -679,7 +819,7 @@ function createHandlers(catalog) {
679
819
  catalog.searchCatalog('assets', { freeOnly: true, sort: 'trending', limit }),
680
820
  catalog.searchCatalog('design_systems', { sort: 'trending', limit }),
681
821
  ]);
682
- return textResult({ window: 'last 90 days by views (7-day seed is empty: newest catalogue rows are months old)', components: r[0], skills: r[1], assets: r[2], design_systems: r[3] }); },
822
+ return textResult({ window: 'last 90 days by views', window_note: '7-day seed is empty (newest catalogue rows are months old); 90d keeps trending meaningful. Components here are low-signal (views 0-5): prefer skills/assets trending.', components: r[0], skills: r[1], assets: r[2], design_systems: r[3] }); },
683
823
  aura_categories: async () => textResult({ categories: await catalog.categoryCounts() }),
684
824
  aura_bundle: async (a) => { a = a || {}; const ids = idList(a.ids, 'ids') || idList(a.slugs, 'slugs');
685
825
  if (!ids || !ids.length) toolFn_bad('provide ids (array of 1-8 numbers/strings) or slugs (array of strings)');
package/flow/app.js CHANGED
@@ -1,40 +1,178 @@
1
- (function(){
2
- 'use strict';
3
- var root=document.documentElement;
4
- try{var s=localStorage.getItem('acm-theme');if(s==='dark'||s==='light')root.setAttribute('data-theme',s);else root.setAttribute('data-theme','dark');}catch(e){root.setAttribute('data-theme','dark');}
5
- var t=document.getElementById('themeBtn');
6
- function paint(){if(t)t.textContent=root.getAttribute('data-theme')==='dark'?'\u263E':'\u2600';}
7
- if(t)t.addEventListener('click',function(){var n=root.getAttribute('data-theme')==='dark'?'light':'dark';root.setAttribute('data-theme',n);try{localStorage.setItem('acm-theme',n);}catch(e){}paint();});
8
- paint();
9
- var here=(location.pathname.split('/').pop()||'index.html').toLowerCase().split('?')[0].split('#')[0];
10
- Array.prototype.forEach.call(document.querySelectorAll('.tabs a'),function(a){var h=(a.getAttribute('href')||'').toLowerCase().split('?')[0].split('#')[0].replace('./','');a.classList.toggle('on',h===here||(here===''&&h==='index.html'));});
11
- var nodes=[document.getElementById('n0'),document.getElementById('n1'),document.getElementById('n2')];
12
- var ni=0;function tick(){nodes.forEach(function(n,k){if(n)n.classList.toggle('live',k===ni%3);});ni++;}
13
- if(nodes[0]){tick();setInterval(tick,1600);}
14
- var io=null;
15
- function reveal(){var els=document.querySelectorAll('.reveal');if(!('IntersectionObserver' in window)){Array.prototype.forEach.call(els,function(e){e.classList.add('in');});return;}io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target);}});},{threshold:.12});Array.prototype.forEach.call(els,function(e){io.observe(e);});}
16
- if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',reveal);else reveal();
17
- var cv=document.querySelector('canvas.gl');
18
- if(cv&&!matchMedia('(prefers-reduced-motion: reduce)').matches){
19
- (function(){
20
- var gl=cv.getContext('webgl',{antialias:false,alpha:true});
21
- if(!gl)return;
22
- function sh(t,src){var s=gl.createShader(t);gl.shaderSource(s,src);gl.compileShader(s);return gl.getShaderParameter(s,gl.COMPILE_STATUS)?s:null;}
23
- var vs=sh(gl.VERTEX_SHADER,'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}');
24
- var fs=sh(gl.FRAGMENT_SHADER,'precision highp float;uniform vec2 R;uniform float T;void main(){vec2 uv=gl_FragCoord.xy/R;vec2 p=uv*2.-1.;p.x*=R.x/R.y;p.y+=.8;float r=length(p);float a=atan(p.y,p.x);float dist=abs(r-1.6);float warp=sin(r*4.-T*.5)*.3;float lines=sin((a+warp)*80.+T*2.);lines=smoothstep(.85,1.,lines);float mask=smoothstep(.5,0.,dist);float glow=.05/(dist*dist+.05);vec3 c1=vec3(0.,.4,1.);vec3 c2=vec3(.8,.1,.9);vec3 base=mix(c1,c2,sin(a*2.+T*.5)*.5+.5);vec3 line=mix(base,vec3(1.),.4);vec3 col=line*lines*mask*2.5+base*glow*1.5;col*=smoothstep(1.5,-.5,p.y);col*=smoothstep(3.,1.,r);gl_FragColor=vec4(col,1.);}');
25
- if(!vs||!fs)return;
26
- var pr=gl.createProgram();gl.attachShader(pr,vs);gl.attachShader(pr,fs);gl.linkProgram(pr);
27
- if(!gl.getProgramParameter(pr,gl.LINK_STATUS))return;
28
- var buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);
29
- gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,1,1,1,-1,-1,1,-1]),gl.STATIC_DRAW);
30
- var loc=gl.getAttribLocation(pr,'p');var uR=gl.getUniformLocation(pr,'R');var uT=gl.getUniformLocation(pr,'T');
31
- function fit(){var d=Math.min(devicePixelRatio||1,1.5);var w=cv.clientWidth*d|0,h=cv.clientHeight*d|0;if(cv.width!==w||cv.height!==h){cv.width=w;cv.height=h;gl.viewport(0,0,w,h);}}
32
- var run=true;
33
- new IntersectionObserver(function(es){run=es[0].isIntersecting;},{threshold:0}).observe(cv);
34
- document.addEventListener('visibilitychange',function(){fit();});
35
- addEventListener('resize',fit,{passive:true});fit();
36
- function frame(t){requestAnimationFrame(frame);if(!run||document.hidden)return;gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT);gl.useProgram(pr);gl.bindBuffer(gl.ARRAY_BUFFER,buf);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);gl.enableVertexAttribArray(loc);gl.uniform2f(uR,cv.width,cv.height);gl.uniform1f(uT,t*.001);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);}
37
- requestAnimationFrame(frame);
1
+ (()=>{"use strict";const r=document.documentElement;
2
+ r.classList.remove("no-js");
3
+ const reduced=matchMedia("(prefers-reduced-motion: reduce)").matches;
4
+ function syncMeta(){const dark=r.dataset.theme!=="light";
5
+ let single=document.querySelector('meta[name="theme-color"]:not([media])');
6
+ if(!single){single=document.createElement("meta");single.name="theme-color";document.head.appendChild(single)}
7
+ single.content=dark?"#000000":"#ffffff"}
8
+ function paint(){const t=document.getElementById("themeBtn");
9
+ if(t){t.textContent=r.dataset.theme==="dark"?"\u263E":"\u2600"}}
10
+ function setTheme(n){r.dataset.theme=n;
11
+ try{localStorage.setItem("acm-theme",n)}catch(e){}paint();syncMeta()}
12
+ const tb=document.getElementById("themeBtn");
13
+ if(tb){tb.addEventListener("click",function(){
14
+ const n=r.dataset.theme==="dark"?"light":"dark";
15
+ if(document.startViewTransition&&!reduced){document.startViewTransition(function(){setTheme(n)})}
16
+ else{setTheme(n)}})}
17
+ paint();syncMeta();
18
+ const panel=document.getElementById("mpanel"),menu=document.getElementById("menuBtn");
19
+ function openM(o){if(!panel){return}panel.classList.toggle("open",o);
20
+ document.body.style.overflow=o?"hidden":""}
21
+ if(menu&&panel){menu.addEventListener("click",function(){openM(!panel.classList.contains("open"))})}
22
+ if(panel){panel.addEventListener("click",function(e){
23
+ if(e.target.closest("[data-close]")||e.target===panel){openM(false)}})}
24
+ addEventListener("keydown",function(e){if(e.key==="Escape"){openM(false)}});
25
+ const here=(location.pathname.split("/").pop()||"index.html").toLowerCase().split("?")[0];
26
+ function mark(a){const h=(a.getAttribute("href")||"").toLowerCase().replace("./","");
27
+ a.classList.toggle("on",h===here||(here===""&&h==="index.html"))}
28
+ document.querySelectorAll(".tabs a").forEach(mark);
29
+ document.querySelectorAll(".msheet a[data-nav]").forEach(mark);
30
+ const nav=document.querySelector(".nav");
31
+ addEventListener("scroll",function(){if(nav){nav.classList.toggle("scrolled",(scrollY||0)>8)}},{passive:true});
32
+ document.querySelectorAll(".card,.fnode").forEach(function(c){
33
+ c.addEventListener("pointermove",function(e){
34
+ const b=c.getBoundingClientRect();
35
+ c.style.setProperty("--fx",((e.clientX-b.left)/b.width*100)+"%");
36
+ c.style.setProperty("--fy",((e.clientY-b.top)/b.height*100)+"%")},{passive:true})});
37
+ document.querySelectorAll("[data-copy]").forEach(function(btn){
38
+ btn.addEventListener("click",function(){
39
+ const pre=btn.parentElement.querySelector("pre");if(!pre){return}
40
+ const done=function(t){btn.textContent=t;setTimeout(function(){btn.textContent="Copy"},1400)};
41
+ if(navigator.clipboard&&navigator.clipboard.writeText){
42
+ navigator.clipboard.writeText(pre.innerText).then(function(){done("Copied")},function(){done("Select + Ctrl+C")})}
43
+ else{done("Select + Ctrl+C")}})});
44
+ document.querySelectorAll(".ctabs").forEach(function(g){
45
+ const btns=Array.prototype.slice.call(g.querySelectorAll("button"));
46
+ btns.forEach(function(b){b.addEventListener("click",function(){
47
+ btns.forEach(function(x){x.classList.remove("on")});b.classList.add("on");
48
+ const id=b.getAttribute("data-tab");if(!id||!g.parentElement){return}
49
+ const panes=g.parentElement.querySelectorAll("[data-pane]");
50
+ panes.forEach(function(p){p.hidden=p.getAttribute("data-pane")!==id})})})});
51
+ const yr=document.getElementById("yr");if(yr){yr.textContent=new Date().getFullYear()}
52
+ const hasGsap=(typeof gsap!=="undefined")&&(typeof ScrollTrigger!=="undefined");
53
+ if(!hasGsap||reduced){r.classList.add("no-gsap");
54
+ const els=document.querySelectorAll(".reveal");
55
+ if(!("IntersectionObserver" in window)){els.forEach(function(e){e.classList.add("in")})}
56
+ else{const io=new IntersectionObserver(function(es){
57
+ es.forEach(function(en){if(en.isIntersecting){en.target.classList.add("in");io.unobserve(en.target)}})},{threshold:.1});
58
+ els.forEach(function(e){io.observe(e)})}}
59
+ if(hasGsap&&!reduced){try{
60
+ r.style.scrollBehavior="auto";
61
+ gsap.registerPlugin(ScrollTrigger);
62
+ gsap.from(".hero-in > *",{y:26,opacity:0,duration:.8,stagger:.09,ease:"power3.out",clearProps:"all"});
63
+ gsap.utils.toArray(".reveal").forEach(function(el){
64
+ if(el.matches(".grid3 .card")){return}
65
+ gsap.fromTo(el,{y:24,opacity:0},{y:0,opacity:1,duration:.7,ease:"power3.out",
66
+ scrollTrigger:{trigger:el,start:"top 88%",once:true}})});
67
+ const batchCards=gsap.utils.toArray(".grid3 .card");
68
+ if(batchCards.length){gsap.set(batchCards,{y:28,opacity:0});
69
+ ScrollTrigger.batch(batchCards,{start:"top 88%",once:true,interval:.1,batchMax:3,
70
+ onEnter:function(batch){gsap.to(batch,{y:0,opacity:1,duration:.7,
71
+ ease:"power3.out",stagger:.12,overwrite:true})}});}
72
+ document.querySelectorAll(".stat b[data-count]").forEach(function(b){
73
+ const end=parseFloat(b.getAttribute("data-count"));const o={v:0};
74
+ function tick(){b.textContent=Math.round(o.v).toLocaleString("en-US")}
75
+ function play(){gsap.to(o,{v:end,duration:1.4,ease:"power2.out",onUpdate:tick})}
76
+ ScrollTrigger.create({trigger:b,start:"top 92%",once:true,onEnter:play});
77
+ });
78
+ addEventListener("load",function(){ScrollTrigger.refresh()});
79
+ }catch(err){r.classList.add("no-gsap")}}
80
+ (function(){if(reduced){return}
81
+ const cv=document.createElement("canvas");cv.id="bg-net";cv.setAttribute("aria-hidden","true");
82
+ document.body.prepend(cv);const cx=cv.getContext("2d");
83
+ let W=0,H=0,pts=[],run=true,mx=-999,my=-999;
84
+ const dark=function(){return r.dataset.theme!=="light"};
85
+ function fit(){const d=Math.min(devicePixelRatio||1,1.5);
86
+ W=innerWidth;H=innerHeight;cv.width=W*d;cv.height=H*d;
87
+ cv.style.width=W+"px";cv.style.height=H+"px";cx.setTransform(d,0,0,d,0,0);
88
+ const n=Math.min(90,(W*H/22000)|0);pts=[];
89
+ for(let i=0;i<n;i++){pts.push({x:Math.random()*W,y:Math.random()*H,
90
+ vx:(Math.random()-.5)*.35,vy:(Math.random()-.5)*.35})}}
91
+ addEventListener("resize",fit,{passive:true});fit();
92
+ addEventListener("pointermove",function(e){mx=e.clientX;my=e.clientY},{passive:true});
93
+ new IntersectionObserver(function(es){run=es[0].isIntersecting},{threshold:0}).observe(cv);
94
+ document.addEventListener("visibilitychange",function(){run=!document.hidden});
95
+ (function frame(){requestAnimationFrame(frame);
96
+ if(!run||document.hidden){return}
97
+ cx.clearRect(0,0,W,H);
98
+ const mono=dark()?"255,255,255":"0,0,0";
99
+ for(const p of pts){p.x+=p.vx;p.y+=p.vy;
100
+ if(p.x<0||p.x>W){p.vx*=-1}if(p.y<0||p.y>H){p.vy*=-1}}
101
+ for(let i=0;i<pts.length;i++){const a=pts[i];
102
+ for(let j=i+1;j<pts.length;j++){const b=pts[j];
103
+ const dx=a.x-b.x,dy=a.y-b.y,d=dx*dx+dy*dy;
104
+ if(d<150*150){cx.strokeStyle="rgba("+mono+","+(0.10*(1-d/22500)).toFixed(3)+")";
105
+ cx.lineWidth=1;cx.beginPath();cx.moveTo(a.x,a.y);cx.lineTo(b.x,b.y);cx.stroke()}}}
106
+ for(const p of pts){const dm=(p.x-mx)*(p.x-mx)+(p.y-my)*(p.y-my);
107
+ const glow=dm<120*120?0.5:0.22;
108
+ cx.fillStyle="rgba("+mono+","+glow.toFixed(2)+")";
109
+ cx.beginPath();cx.arc(p.x,p.y,dm<120*120?1.8:1.2,0,6.283);cx.fill()}})();
38
110
  })();
111
+ const prog=document.getElementById("progress"),topBtn=document.getElementById("toTop");
112
+ function onPos(){const h=document.documentElement;
113
+ const max=h.scrollHeight-h.clientHeight;
114
+ const p=max>0?(h.scrollTop||document.body.scrollTop)/max:0;
115
+ if(prog){prog.style.transform="scaleX("+p+")"}
116
+ if(topBtn){topBtn.classList.toggle("show",(h.scrollTop||0)>600)}}
117
+ addEventListener("scroll",onPos,{passive:true});onPos();
118
+ if(topBtn){topBtn.addEventListener("click",function(){
119
+ scrollTo({top:0,behavior:reduced?"auto":"smooth"})})}
120
+ if(hasGsap&&!reduced){try{
121
+ gsap.to(".hero-in",{yPercent:-10,autoAlpha:.25,ease:"none",
122
+ scrollTrigger:{trigger:".hero",start:"top top",end:"bottom top",scrub:true}});
123
+ gsap.utils.toArray(".flow").forEach(function(f){
124
+ const items=f.querySelectorAll(".fnode");
125
+ if(items.length){gsap.fromTo(items,{y:34,autoAlpha:0},
126
+ {y:0,autoAlpha:1,duration:.6,stagger:.18,ease:"power3.out",
127
+ scrollTrigger:{trigger:f,start:"top 82%",once:true}})}});
128
+ }catch(err){}}
129
+ const fine=matchMedia("(pointer:fine)").matches;
130
+ if(fine&&!reduced){
131
+ const dot=document.createElement("div");dot.id="cursor";dot.setAttribute("aria-hidden","true");
132
+ const ring=document.createElement("div");ring.id="aura";ring.setAttribute("aria-hidden","true");
133
+ document.body.append(dot,ring);
134
+ let x=innerWidth/2,y=innerHeight/2,rx=x,ry=y,sc=1,tsc=1;
135
+ addEventListener("pointermove",function(e){x=e.clientX;y=e.clientY;
136
+ const t=e.target.closest?e.target.closest("a,button,.card,.fnode"):null;
137
+ tsc=t?2.1:1},{passive:true});
138
+ (function loop(){rx+=(x-rx)*.16;ry+=(y-ry)*.16;sc+=(tsc-sc)*.2;
139
+ dot.style.transform="translate("+x+"px,"+y+"px)";
140
+ ring.style.transform="translate("+rx+"px,"+ry+"px) scale("+sc.toFixed(3)+")";
141
+ requestAnimationFrame(loop)})();
142
+ const hasG=typeof gsap!=="undefined";
143
+ document.querySelectorAll(".hero .btn").forEach(function(b){
144
+ const qx=hasG?gsap.quickTo(b,"x",{duration:.3,ease:"power3"}):null;
145
+ const qy=hasG?gsap.quickTo(b,"y",{duration:.3,ease:"power3"}):null;
146
+ b.addEventListener("pointermove",function(e){
147
+ if(!qx||!qy){return}
148
+ const bb=b.getBoundingClientRect();
149
+ qx((e.clientX-(bb.left+bb.width/2))*.25);
150
+ qy((e.clientY-(bb.top+bb.height/2))*.25)});
151
+ b.addEventListener("pointerleave",function(){if(qx&&qy){qx(0);qy(0)}})});
152
+ document.querySelectorAll(".hero .btn").forEach(function(b){
153
+ if(b.querySelector(".ch")){return}
154
+ const txt=b.textContent;b.setAttribute("aria-label",txt);b.textContent="";
155
+ Array.prototype.forEach.call(txt,function(ch){
156
+ const s=document.createElement("span");s.className="ch";s.setAttribute("aria-hidden","true");
157
+ s.textContent=ch===" "?" ":ch;b.appendChild(s)});
158
+ b.addEventListener("mouseenter",function(){
159
+ if(!hasG){return}
160
+ gsap.fromTo(b.querySelectorAll(".ch"),{y:0},
161
+ {y:-5,duration:.13,ease:"power2.out",stagger:{each:.018,yoyo:true,repeat:1},overwrite:"auto"})})});
162
+ const heroEl=document.querySelector(".hero"),statsEl=document.querySelector(".hero .stats");
163
+ if(heroEl&&statsEl){statsEl.style.transformStyle="preserve-3d";
164
+ heroEl.addEventListener("pointermove",function(e){
165
+ const bb=statsEl.getBoundingClientRect();
166
+ const px=(e.clientX-bb.left)/bb.width-.5,py=(e.clientY-bb.top)/bb.height-.5;
167
+ statsEl.style.transform="perspective(800px) rotateX("+(-py*7).toFixed(2)+"deg) rotateY("+(px*9).toFixed(2)+"deg)"},{passive:true});
168
+ heroEl.addEventListener("pointerleave",function(){
169
+ statsEl.style.transform="perspective(800px)"})}
170
+ let lastY=scrollY||0;
171
+ addEventListener("scroll",function(){
172
+ const yy=scrollY||0;
173
+ if(!(panel&&panel.classList.contains("open"))){
174
+ if(yy>140&&yy>lastY+4&&nav){nav.classList.add("hide")}
175
+ else if((yy<lastY-4||yy<140)&&nav){nav.classList.remove("hide")}}
176
+ lastY=yy},{passive:true});
39
177
  }
40
178
  })();