aura-components-mcp 1.7.1 → 1.7.2

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/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // aura-components-mcp v1.7.1 — bundled (zero deps). Built by swastiksingh-dev.
2
+ // aura-components-mcp v1.7.2 — 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.
@@ -30,7 +30,7 @@ function loadConfig(env = process.env) {
30
30
  codeChars: num(env.AURA_CODE_CHARS, 12_000, { min: 1_000, max: 60_000 }),
31
31
  contentChars: num(env.AURA_CONTENT_CHARS, 12_000, { min: 1_000, max: 60_000 }),
32
32
  freeOnlyDefault: bool(env.AURA_FREE_ONLY_DEFAULT, true),
33
- userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.1",
33
+ userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.2",
34
34
  };
35
35
  }
36
36
 
@@ -150,15 +150,31 @@ const DETAIL_COLS = {
150
150
  };
151
151
 
152
152
  const esc = (s) => String(s).replaceAll('"', '""');
153
- const ilike = (v) => "*" + String(v).replaceAll("*", "").replaceAll(",", " ").trim() + "*";
153
+ // pct: percent-encode chars that break PostgREST query parsing when raw —
154
+ // "#" would start a URL fragment (PGRST100), "," splits value lists,
155
+ // "()" groups logic, quotes break string parsing, raw "%" corrupts decoding.
156
+ // encodeURIComponent deliberately leaves !'()* raw, so escEq finishes the job.
157
+ const pct = (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase();
158
+ const escEq = (s) => encodeURIComponent(String(s)).replace(/[!'()*]/g, pct);
159
+ // ilike patterns keep our own "*" wildcards; everything user-typed that is
160
+ // PostgREST-significant gets encoded so goals like "page #0a0a0b (dark)"
161
+ // match literally instead of crashing the query.
162
+ const ilike = (v) => "*" + String(v).replaceAll("*", "").trim().replace(/[%#()'",]/g, pct) + "*";
154
163
 
155
164
  // Split a goal sentence into significant tokens for OR fallback (stop-word filtered).
156
165
  // Theme words (dark/light) are NOT search tokens — they become rank/filter signals
157
166
  // (see detectThemeHint + scoreByTheme), so "dark cinematic" can't match light rows.
167
+ // Hex codes (#0a0a0b, bare 6-hex with a digit) are NOT search tokens either —
168
+ // catalogue titles never contain them, so they only poison overlap counts while
169
+ // still riding along for palette synthesis elsewhere. 3-hex words without #
170
+ // ("red", "tan") are kept: too often real words to drop.
158
171
  const STOP = new Set("a,an,the,for,with,and,or,of,to,in,on,my,new,free,also,that,this,from,into,plus,vs,top,best,up".split(","));
159
172
  const THEME_WORDS = new Set(["dark", "light", "midnight", "noir", "black", "white", "bright", "airy", "minimal"]);
160
173
  function queryTokens(q, max = 4) {
161
- return String(q ?? "").toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 2 && !STOP.has(t) && !THEME_WORDS.has(t)).slice(0, max);
174
+ const raw = String(q ?? "").toLowerCase();
175
+ const hashed = new Set([...raw.matchAll(/#([0-9a-z]+)/g)].map((m) => m[1]));
176
+ const isHexNoise = (t) => hashed.has(t) || (/^(?=.*\d)[0-9a-f]{6}$/.test(t));
177
+ return raw.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 2 && !STOP.has(t) && !THEME_WORDS.has(t) && !isHexNoise(t)).slice(0, max);
162
178
  }
163
179
  // Theme hint from the raw query: explicit signal, separate from match tokens.
164
180
  function detectThemeHint(q) {
@@ -169,6 +185,19 @@ function detectThemeHint(q) {
169
185
  if (light && !dark) return "light";
170
186
  return null;
171
187
  }
188
+ // Light suffix stemming so inflected query words still hit literal titles
189
+ // (glassy→glass, magazines→magazine). Same rule family as the award ranker;
190
+ // applied symmetrically to query tokens AND haystacks, so counts only grow
191
+ // when both sides agree on the stem — honest-empty behavior preserved.
192
+ function stemTok(t) {
193
+ let s = String(t ?? "").toLowerCase();
194
+ if (s.length > 5 && s.endsWith("ing")) s = s.slice(0, -3);
195
+ else if (s.length > 4 && s.endsWith("ies")) s = s.slice(0, -3) + "y";
196
+ else if (s.length > 4 && s.endsWith("es") && /(ses|xes|zes|ches|shes)$/.test(s)) s = s.slice(0, -2);
197
+ else if (s.length > 4 && s.endsWith("s") && !s.endsWith("ss")) s = s.slice(0, -1);
198
+ else if (s.length > 4 && s.endsWith("y")) s = s.slice(0, -1);
199
+ return s;
200
+ }
172
201
  // Rank rows: +2 per matched token in title, +1 in description/tags, theme match first.
173
202
  // Returns { rows, scored } where scored explains the top pick for fallback_score.
174
203
  function scoreRows(kind, rows, toks, themeHint) {
@@ -177,7 +206,11 @@ function scoreRows(kind, rows, toks, themeHint) {
177
206
  const desc = String(r.description || "").toLowerCase();
178
207
  const tags = ((r.tags || []).map((x) => String(x).toLowerCase()).join(" "));
179
208
  let s = 0;
180
- for (const t of toks) { if (title.includes(t)) s += 2; else if (desc.includes(t) || tags.includes(t)) s += 1; }
209
+ for (const raw of toks) {
210
+ const t = stemTok(raw);
211
+ if (title.includes(raw) || (t !== raw && title.includes(t))) s += 2;
212
+ else if (desc.includes(raw) || tags.includes(raw) || (t !== raw && (desc.includes(t) || tags.includes(t)))) s += 1;
213
+ }
181
214
  const th = r.facets && r.facets.theme;
182
215
  const themeOk = !themeHint || th === themeHint || th === "mixed" || th === "unknown";
183
216
  if (themeHint && th === themeHint) s += 3;
@@ -198,7 +231,8 @@ function matchOverlap(kind, toks, item) {
198
231
  let n = 0;
199
232
  for (const t of list) {
200
233
  const tok = String(t ?? "").toLowerCase();
201
- if (tok && title.includes(tok)) n++;
234
+ const stem = stemTok(tok);
235
+ if (tok && (title.includes(tok) || (stem !== tok && title.includes(stem)))) n++;
202
236
  }
203
237
  return n;
204
238
  }
@@ -211,7 +245,8 @@ function matchOverlap(kind, toks, item) {
211
245
  let n = 0;
212
246
  for (const t of list) {
213
247
  const tok = String(t ?? "").toLowerCase();
214
- if (tok && hay.includes(tok)) n++;
248
+ const stem = stemTok(tok);
249
+ if (tok && (hay.includes(tok) || (stem !== tok && hay.includes(stem)))) n++;
215
250
  }
216
251
  return n;
217
252
  }
@@ -517,7 +552,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
517
552
  if ((kind === "design_systems" || kind === "skills") && rows.length) {
518
553
  const topIds = rows.slice(0, 5).map((r) => r.id);
519
554
  try {
520
- const idList = topIds.map((id) => encodeURIComponent(String(id))).join(",");
555
+ const idList = topIds.map((id) => escEq(id)).join(",");
521
556
  const sel = kind === "design_systems" ? "id,content,preview_html" : "id,content,source_url";
522
557
  const { rows: full } = await fetcher.getJson(base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&id=in.(" + idList + ")", headers);
523
558
  const byId = new Map(full.map((r) => [String(r.id), r]));
@@ -592,14 +627,24 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
592
627
  if (fallback === "popular-safety-net") out.suggested_queries = suggestList();
593
628
  }
594
629
  else if ((!rows || !rows.length) && toks.length) { out.isFallback = false; out.suggested_queries = suggestList(); }
595
- // Zero-overlap refusal: fallback engaged but top row matches 0/N tokens —
596
- // honest empty instead of irrelevant popular rows. Direct hits never refused.
597
- // Kind-aware: components/assets stay title-only strict; skills/systems use the
598
- // wider net (title+description+tags+snippets) via matchOverlap.
630
+ // Zero-overlap refusal: fallback engaged but NO row matches ≥1 token —
631
+ // honest empty instead of irrelevant popular rows. Direct hits never
632
+ // refused. The gate judges the BEST row, not just the first: a single
633
+ // irrelevant views-leader must not veto relevant rows below it.
634
+ // Matching rows are re-ranked first (theme-ok, then overlap, views
635
+ // order kept within ties) and fallback_score is refreshed to the new
636
+ // top row so the disclosed score always describes returned rows.
637
+ // Kind-aware: components/assets stay title-only strict; skills/systems
638
+ // use the wider net (title+description+tags+snippets) via matchOverlap.
599
639
  if (fallback != null && toks.length > 0) {
600
- const top = out.items[0];
601
- const hits2 = matchOverlap(kind, toks, top);
602
- if (hits2 === 0) {
640
+ const themeOf = (it) => (it && it.facets && it.facets.theme) || "unknown";
641
+ const themeOkOf = (it) => !effTheme || themeOf(it) === effTheme || themeOf(it) === "mixed" || themeOf(it) === "unknown";
642
+ const scored = out.items.map((it) => ({ it, hits: matchOverlap(kind, toks, it), themeOk: themeOkOf(it) }));
643
+ scored.sort((a, b) => (((b.themeOk ? 1 : 0) - (a.themeOk ? 1 : 0)) || (b.hits - a.hits)));
644
+ // Refusal judges the GLOBAL best: theme-first ordering must never
645
+ // let a 0-hit row veto a matching row below it.
646
+ const best = scored.length ? Math.max(...scored.map((s) => s.hits)) : 0;
647
+ if (best === 0) {
603
648
  const refused = fallback;
604
649
  out.items = [];
605
650
  out.total = 0;
@@ -610,6 +655,9 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
610
655
  out.refused_fallback = refused;
611
656
  out.suggested_queries = suggestList();
612
657
  out.note = "Top fallback result matched 0/" + toks.length + " title tokens — empty instead of irrelevant popular rows. Try a suggested query.";
658
+ } else {
659
+ out.items = scored.map((s) => s.it);
660
+ out.fallback_score = "token-overlap:" + best + "/" + toks.length + "-terms" + (effTheme ? ";theme:" + effTheme : "");
613
661
  }
614
662
  }
615
663
  if (slim) out.items = slimRows(kind, out.items);
@@ -631,10 +679,10 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
631
679
  const sel = DETAIL_COLS[kind];
632
680
  // design_systems + skills: uuid id vs slug; components: numeric id vs slug
633
681
  const filt = (kind === "design_systems" || kind === "skills")
634
- ? (isUuid ? "id=eq." + encodeURIComponent(String(idOrSlug)) : "slug=eq." + encodeURIComponent(String(idOrSlug)))
682
+ ? (isUuid ? "id=eq." + escEq(idOrSlug) : "slug=eq." + escEq(idOrSlug))
635
683
  : (kind === "components" && !isNum
636
- ? "slug=eq." + encodeURIComponent(String(idOrSlug))
637
- : "id=eq." + encodeURIComponent(String(idOrSlug)));
684
+ ? "slug=eq." + escEq(idOrSlug)
685
+ : "id=eq." + escEq(idOrSlug));
638
686
  const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&" + filt + "&limit=1";
639
687
  const { rows } = await fetcher.getJson(url, headers);
640
688
  if (!rows.length) { const e = new Error("not found: " + kind + " " + idOrSlug); e.code = "NOT_FOUND"; throw e; }
@@ -778,7 +826,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
778
826
  const lim = Math.max(1, Math.min(limit || 3, 8));
779
827
  const sel = LIST_COLS[kind];
780
828
  const idCol = (kind === "components") ? "id" : "id";
781
- const selfId = encodeURIComponent(String(item.id));
829
+ const selfId = escEq(item.id);
782
830
  const premF = (freeOnly && (kind === "components" || kind === "assets")) ? "&premium=eq.false" : "";
783
831
  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;
784
832
  try {
@@ -793,7 +841,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
793
841
  }
794
842
 
795
843
  // ---- protocol.mjs ----
796
- const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.1" };
844
+ const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.2" };
797
845
  // Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
798
846
  // One interface: createSession(send) -> { dispatch(msg) }. No business logic.
799
847
 
@@ -1630,6 +1678,1551 @@ function createVersion({ fetcher, running, now = () => Date.now() }) {
1630
1678
  return { getVersion };
1631
1679
  }
1632
1680
 
1681
+ // ---- awards.mjs ----
1682
+ // Module: awards — award-website discovery, retrieval, parsing, ranking.
1683
+ // Reimplements the useful Awwwards-mcp behaviors (site discovery, detail
1684
+ // design-DNA, elements, taxonomy, winners) in Aura's native zero-dep style:
1685
+ // pure parsers + polite fetch client + deterministic local ranking.
1686
+ // Upstream inspiration: INSANE0777/Awwwards-mcp (MIT, (c) 2026 Afjal) —
1687
+ // behavior reimplemented here, not copied; see THIRD_PARTY_NOTICES.md.
1688
+ // No invented metadata: every record carries source/evidence fields.
1689
+
1690
+ const AWARD_SCHEMA_VERSION = 1;
1691
+ const AWARD_BASE_URL = "https://www.awwwards.com";
1692
+ const AWARD_ASSETS_URL = "https://assets.awwwards.com";
1693
+ const AWARD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 aura-components-mcp/1.7.2";
1694
+ const AWARD_ALLOWED_HOSTS = ["www.awwwards.com", "awwwards.com", "assets.awwwards.com"];
1695
+ const AWARD_TIMEOUT_MS = 10000;
1696
+ const AWARD_MAX_BYTES = 2 * 1024 * 1024;
1697
+ const AWARD_RATE_MS = 1000;
1698
+ const AWARD_FILTERS = { sotd: "websites/sites_of_the_day", developer: "websites/developer", honorable: "websites/honorable" };
1699
+ const AWARD_FILTER_NAMES = ["sotd", "developer", "honorable"];
1700
+ const AWARD_SECTIONS = ["hero", "proof", "features", "showcase", "pricing", "testimonials", "faq", "cta", "footer"];
1701
+
1702
+ function awardErr(code, message) {
1703
+ const e = new Error(message);
1704
+ e.code = code;
1705
+ return e;
1706
+ }
1707
+ function awardBad(message) { return awardErr("BAD_ARGS", message); }
1708
+ function awardNotFound(message) { return awardErr("NOT_FOUND", message); }
1709
+ function awardUpstream(message) { return awardErr("UPSTREAM_ERROR", message); }
1710
+
1711
+ const AWARD_ENTITIES = { "&quot;": '"', "&amp;": "&", "&#039;": "'", "&#39;": "'", "&lt;": "<", "&gt;": ">", "&nbsp;": " " };
1712
+ function awardDecodeEntities(s) {
1713
+ return String(s ?? "").replace(/&(?:quot|amp|#0?39|lt|gt|nbsp);/g, (e) => AWARD_ENTITIES[e] ?? e);
1714
+ }
1715
+ function awardStripTags(s) {
1716
+ return awardDecodeEntities(String(s ?? "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
1717
+ }
1718
+ function awardNormalizeSlug(s) {
1719
+ const v = String(s ?? "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1720
+ if (!v || v.length > 120) throw awardBad("slug must be 1-120 chars of [a-z0-9-], got: " + String(s).slice(0, 80));
1721
+ return v;
1722
+ }
1723
+
1724
+ // ---- URL safety (SSRF guard) ----
1725
+ const AWARD_BLOCKED_HOST_RES = [/^localhost$/i, /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /^0\.0\.0\.0$/, /^\[?::1\]?$/, /\.local$/i, /^metadata\.google\.internal$/i];
1726
+ function awardIsBlockedHost(hostname) {
1727
+ const h = String(hostname || "").toLowerCase().replace(/\.$/, "");
1728
+ for (const re of AWARD_BLOCKED_HOST_RES) if (re.test(h)) return true;
1729
+ return false;
1730
+ }
1731
+ function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
1732
+ if (typeof raw !== "string" || !raw.trim()) throw awardBad("url must be a non-empty http(s) string");
1733
+ if (raw.length > 2000) throw awardBad("url too long (max 2000 chars)");
1734
+ let u;
1735
+ try { u = new URL(raw.trim()); } catch { throw awardBad("url is not parseable: " + raw.slice(0, 120)); }
1736
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw awardBad("only http/https urls are allowed, got: " + u.protocol);
1737
+ if (u.username || u.password) throw awardBad("urls with credentials are not allowed");
1738
+ if (awardIsBlockedHost(u.hostname) && !allowLocal) {
1739
+ const e = new Error("private/local target blocked (pass allowLocal:true for loopback dev builds): " + u.hostname);
1740
+ e.code = "UNSUPPORTED_OPERATION";
1741
+ throw e;
1742
+ }
1743
+ if (u.hostname === "169.254.169.254" || /metadata/i.test(u.hostname)) {
1744
+ const e = new Error("cloud metadata endpoints are never fetched");
1745
+ e.code = "UNSUPPORTED_OPERATION";
1746
+ throw e;
1747
+ }
1748
+ return u.toString();
1749
+ }
1750
+ function awardValidateAwardPath(pathOrUrl) {
1751
+ const s = String(pathOrUrl ?? "");
1752
+ if (/^https?:\/\//i.test(s)) {
1753
+ const u = new URL(s);
1754
+ if (!AWARD_ALLOWED_HOSTS.includes(u.hostname.toLowerCase())) throw awardBad("award source host not allowed: " + u.hostname);
1755
+ return u.pathname + (u.search || "");
1756
+ }
1757
+ if (!s.startsWith("/")) throw awardBad("award path must start with / (e.g. /sites/slug)");
1758
+ if (s.includes("..") || s.includes("\\") || /[\s<>"]/.test(s)) throw awardBad("unsafe award path");
1759
+ return s;
1760
+ }
1761
+
1762
+ // ---- Filter URL builder (one filter per URL; rest applied client-side) ----
1763
+ function awardFilterUrl(filters = {}) {
1764
+ if (filters.color) {
1765
+ const c = String(filters.color).replace("#", "").toUpperCase();
1766
+ if (!/^[0-9A-F]{6}$/.test(c)) throw awardBad("color must be a hex like #FF5733");
1767
+ return AWARD_BASE_URL + "/websites/%23" + c + "/";
1768
+ }
1769
+ if (filters.award) {
1770
+ if (!AWARD_FILTERS[filters.award]) throw awardBad("award must be one of " + AWARD_FILTER_NAMES.join("|"));
1771
+ return AWARD_BASE_URL + "/" + AWARD_FILTERS[filters.award] + "/";
1772
+ }
1773
+ const tag = filters.technology ?? (Array.isArray(filters.tags) ? filters.tags[0] : null);
1774
+ if (tag) {
1775
+ const t = String(tag).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
1776
+ if (!t || t.length > 60) throw awardBad("tag/technology must be 2-60 chars");
1777
+ return AWARD_BASE_URL + "/websites/" + encodeURIComponent(t) + "/";
1778
+ }
1779
+ return AWARD_BASE_URL + "/websites/";
1780
+ }
1781
+ function awardSiteUrl(slug) { return AWARD_BASE_URL + "/sites/" + awardNormalizeSlug(slug); }
1782
+ function awardThumbnailUrl(thumbPath, size = 880) {
1783
+ const dim = size === 440 ? "440_330" : "880_660";
1784
+ return AWARD_ASSETS_URL + "/awards/media/cache/thumb_" + dim + "/" + String(thumbPath).replace(/^\/+/, "");
1785
+ }
1786
+ function awardElementUrl(mediaPath) { return AWARD_ASSETS_URL + "/awards/" + String(mediaPath).replace(/^\/+/, ""); }
1787
+
1788
+ // ---- Parsers (scoped, fixture-driven, resilient to missing fields) ----
1789
+ const AWARD_LABELS = { sotd: "Site of the Day", dev: "Developer Award", hm: "Honorable Mention", sotm: "Site of the Month", mobile: "Mobile Excellence", ecom: "E-Commerce Award" };
1790
+ function awardParseListing(html) {
1791
+ const src = String(html || "");
1792
+ if (!src) return [];
1793
+ const sites = [];
1794
+ const seen = new Set();
1795
+ const parts = src.split('data-collectable-model-value="');
1796
+ const blobParts = parts.slice(1);
1797
+ for (const part of blobParts) {
1798
+ const end = part.indexOf('">');
1799
+ if (end < 0 || end > 20000) continue;
1800
+ let meta;
1801
+ try { meta = JSON.parse(awardDecodeEntities(part.slice(0, end))); } catch { continue; }
1802
+ if (!meta || !meta.slug || !meta.title) continue;
1803
+ if (meta.type && meta.type !== "submission") continue;
1804
+ const slug = String(meta.slug).toLowerCase();
1805
+ if (seen.has(slug)) continue;
1806
+ const card = part.slice(end, end + 8000);
1807
+ const detailMatch = card.match(/href="(\/sites\/[^"#?]+)"/);
1808
+ if (!detailMatch) continue;
1809
+ const liveMatch = card.match(/class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/);
1810
+ const awards = [...new Set([...card.matchAll(/budget-tag--([a-z-]+)/g)].map((m) => AWARD_LABELS[m[1]] ?? m[1]))];
1811
+ const studioMatch = card.match(/avatar-name__title">([^<]+)</);
1812
+ seen.add(slug);
1813
+ sites.push({
1814
+ slug,
1815
+ title: awardDecodeEntities(meta.title).trim(),
1816
+ createdAt: Number(meta.createdAt) || 0,
1817
+ tags: Array.isArray(meta.tags) ? meta.tags.map((t) => awardDecodeEntities(t)).filter(Boolean) : [],
1818
+ thumbnailPath: (meta.images && meta.images.thumbnail) ? String(meta.images.thumbnail) : "",
1819
+ liveUrl: liveMatch ? awardDecodeEntities(liveMatch[1]) : null,
1820
+ detailPath: detailMatch[1],
1821
+ awards,
1822
+ studio: studioMatch ? awardDecodeEntities(studioMatch[1]).trim() : null,
1823
+ source: "awwwards",
1824
+ sourceUrl: AWARD_BASE_URL + detailMatch[1],
1825
+ });
1826
+ }
1827
+ // Fallback: plain /sites/ anchors only when the blob scheme is entirely
1828
+ // absent (true drift). Filtered blobs (collections, untitled) mean the
1829
+ // parser saw the page correctly and found no sites — return empty.
1830
+ if (!sites.length && !blobParts.length) {
1831
+ const anchors = [...src.matchAll(/href="\/sites\/([a-z0-9-]{1,120})\/?"[^>]*>([^<]{1,120})</gi)];
1832
+ for (const m of anchors) {
1833
+ const slug = m[1].toLowerCase();
1834
+ if (seen.has(slug)) continue;
1835
+ seen.add(slug);
1836
+ sites.push({ slug, title: awardStripTags(m[2]) || slug, createdAt: 0, tags: [], thumbnailPath: "", liveUrl: null, detailPath: "/sites/" + slug, awards: [], studio: null, source: "awwwards", sourceUrl: AWARD_BASE_URL + "/sites/" + slug });
1837
+ if (sites.length >= 50) break;
1838
+ }
1839
+ }
1840
+ return sites;
1841
+ }
1842
+ function awardIsNomineePage(html) { return /<h2 class="text-default">\s*Nominee\b/.test(String(html || "")); }
1843
+ function awardParseScore(html) {
1844
+ const m = /c-heading-score__note[^>]*>[^<]*?([\d]+(?:\.\d{1,2})?)/.exec(String(html || ""));
1845
+ return m ? parseFloat(m[1]) : null;
1846
+ }
1847
+ function awardParseDetail(html, slug) {
1848
+ const src = String(html || "");
1849
+ const norm = awardNormalizeSlug(slug);
1850
+ const palette = [...new Set([...src.matchAll(/<strong>HEX<\/strong>\s*#([0-9A-Fa-f]{6})/g)].map((m) => "#" + m[1].toUpperCase()))];
1851
+ const techTitle = src.search(/Technologies\s*(&|&amp;)\s*Tools<\/h2>/);
1852
+ const techIdx = techTitle >= 0 ? techTitle : -1;
1853
+ const techSection = techIdx >= 0 ? src.slice(techIdx, techIdx + 6000) : "";
1854
+ const technologies = [...new Set([...techSection.matchAll(/class="button button--tag"[^>]*>([^<]+)</g)].map((m) => awardStripTags(m[1])))].filter(Boolean);
1855
+ const elStart = src.indexOf(">Elements</h2>");
1856
+ const elEnd = elStart >= 0 ? src.indexOf(">Color Palette</h2>", elStart) : -1;
1857
+ const elSection = elStart >= 0 && elEnd > elStart ? src.slice(elStart, elEnd) : "";
1858
+ const elements = [...elSection.matchAll(/collectableTitle&quot;:&quot;(.+?)&quot;/g)].map((m) => awardDecodeEntities(m[1])).filter(Boolean);
1859
+ const awards = [...src.matchAll(/(Site of the Day|Developer Award|Honorable Mention|Site of the Month|Mobile Excellence|E-Commerce Award)\s*[-–]\s*([A-Z][a-z]+ \d{1,2}, \d{4})/g)].map((m) => ({ title: m[1], date: m[2] }));
1860
+ const descIdx = src.indexOf(">Description</h2>");
1861
+ const descMatch = descIdx >= 0 ? src.slice(descIdx, descIdx + 3000).match(/<h3 class="heading-6">([\s\S]{0,2000}?)<\/h3>/) : null;
1862
+ const curated = descMatch ? awardStripTags(descMatch[1]) || null : null;
1863
+ const ogDesc = (src.match(/property="og:description" content="([^"]*)"/) || [])[1];
1864
+ const ogImage = (src.match(/property="og:image" content="([^"]+)"/) || [])[1];
1865
+ const h1 = src.match(/<h1 class="heading-1[^"]*">\s*<a href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
1866
+ const liveFallback = h1 ? null : src.match(/href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*target="_blank" rel="noopener"/);
1867
+ const titleMatch = src.match(/property="og:title" content="([^"]+)"/);
1868
+ return {
1869
+ slug: norm,
1870
+ title: titleMatch ? awardDecodeEntities(titleMatch[1]) : (h1 ? awardStripTags(h1[2]) : null),
1871
+ description: curated ?? (ogDesc ? awardDecodeEntities(ogDesc).trim() || null : null),
1872
+ palette, technologies, elements, awards,
1873
+ nominee: awardIsNomineePage(src),
1874
+ score: awardParseScore(src),
1875
+ ogImage: ogImage ? awardDecodeEntities(ogImage) : null,
1876
+ liveUrl: (h1 && h1[1]) || (liveFallback && liveFallback[1]) ? awardDecodeEntities((h1 && h1[1]) || liveFallback[1]) : null,
1877
+ source: "awwwards",
1878
+ sourceUrl: awardSiteUrl(norm),
1879
+ retrievedAt: new Date().toISOString(),
1880
+ };
1881
+ }
1882
+ function awardParseCategories(html) {
1883
+ const src = String(html || "");
1884
+ const colors = [...new Set([...src.matchAll(/href="\/websites\/%23([0-9A-Fa-f]{6})\/"/g)].map((m) => "#" + m[1].toUpperCase()))].sort();
1885
+ const filters = [...new Set([...src.matchAll(/href="\/websites\/([a-z0-9-]{2,60})\/"/g)].map((m) => m[1]))].filter((s) => s !== "sites_of_the_day").sort();
1886
+ return { colors, filters };
1887
+ }
1888
+ function awardParseElementsSection(html) {
1889
+ const src = String(html || "");
1890
+ const start = src.indexOf(">Elements</h2>");
1891
+ if (start < 0) return null;
1892
+ const end = src.indexOf(">Color Palette</h2>", start);
1893
+ const section = end > start ? src.slice(start, end) : src.slice(start);
1894
+ const out = [];
1895
+ for (const part of section.split('data-collectable-model-value="').slice(1)) {
1896
+ const stop = part.indexOf('">');
1897
+ if (stop < 0) continue;
1898
+ let blob;
1899
+ try { blob = JSON.parse(awardDecodeEntities(part.slice(0, stop))); } catch { continue; }
1900
+ const mp = blob && blob.collectableImage;
1901
+ if (typeof mp === "string" && /^(element|external)\//.test(mp)) out.push({ title: awardDecodeEntities(String(blob.collectableTitle ?? "")), mediaPath: mp });
1902
+ }
1903
+ return out;
1904
+ }
1905
+ function awardParseElementGallery(html) {
1906
+ const src = String(html || "");
1907
+ const slugs = [...new Set([...src.matchAll(/href="\/inspiration\/([\w-]{1,120})\/?"/g)].map((m) => m[1]))];
1908
+ return slugs;
1909
+ }
1910
+ function awardParseElementPage(html) {
1911
+ const src = String(html || "");
1912
+ const slug = (src.match(/rel="canonical" href="[^"]*?\/inspiration\/([\w-]{1,120})\/?"/) || [])[1] ?? awardParseElementGallery(src)[0] ?? null;
1913
+ if (!slug) return null;
1914
+ const titleRaw = (src.match(/<h1[^>]*>\s*([\s\S]{2,200}?)\s*(?:<small>from<\/small>|<\/h1>)/) || [])[1];
1915
+ const authorRaw = (src.match(/from<\/small>\s*<a(?:[^>"']|"[^"]*"|'[^']*')*>\s*([\s\S]{2,120}?)<\/a>/) || [])[1];
1916
+ const siteSlug = (src.match(/from<\/small>\s*<a[^>]*?href="\/sites\/([\w-]{1,120})"/) || [])[1] ?? null;
1917
+ const builtI = src.indexOf("was built with...</p>");
1918
+ const builtWith = [];
1919
+ if (builtI >= 0) for (const t of src.slice(builtI, builtI + 2000).matchAll(/button--tag[^>]*>([^<]{2,30})</g)) {
1920
+ const v = t[1].trim();
1921
+ if (v && !builtWith.includes(v)) builtWith.push(v);
1922
+ }
1923
+ let mediaPath = null;
1924
+ for (const part of src.split('data-collectable-model-value="').slice(1)) {
1925
+ const stop = part.indexOf('"');
1926
+ if (stop < 0) continue;
1927
+ try {
1928
+ const blob = JSON.parse(awardDecodeEntities(part.slice(0, stop)));
1929
+ if (blob && blob.collectableImage) { mediaPath = blob.collectableImage; break; }
1930
+ } catch { /* ignore */ }
1931
+ }
1932
+ const category = (src.match(/href="\/elements\/([\w-]{1,60})\/"/) || [])[1] ?? null;
1933
+ return {
1934
+ slug,
1935
+ title: titleRaw ? awardStripTags(titleRaw) : slug,
1936
+ author: authorRaw ? awardStripTags(authorRaw) : null,
1937
+ siteSlug, builtWith,
1938
+ related: awardParseElementGallery(src).filter((s) => s !== slug).slice(0, 12),
1939
+ mediaPath, mediaType: mediaPath ? (mediaPath.endsWith(".mp4") ? "video" : "image") : null,
1940
+ mediaUrl: mediaPath ? awardElementUrl(mediaPath) : null,
1941
+ category,
1942
+ source: "awwwards",
1943
+ sourceUrl: AWARD_BASE_URL + "/inspiration/" + slug,
1944
+ };
1945
+ }
1946
+
1947
+ // ---- Deterministic local ranking (token + prefix + BM25-style idf) ----
1948
+ const AWARD_STOP = new Set("a,an,the,for,with,and,or,of,to,in,on,my,new,also,that,this,from,into,plus,vs,top,best,up,site,website,web,design".split(","));
1949
+ function awardTokens(q, max = 6) {
1950
+ return String(q ?? "").toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/[\s-]+/).map((t) => t.trim()).filter((t) => t.length > 1 && !AWARD_STOP.has(t)).slice(0, max);
1951
+ }
1952
+ // Theme words are rank signals, not just match tokens: a "dark …" query
1953
+ // boosts dark-tagged sites even when no title literally says "dark".
1954
+ function awardThemeHint(q) {
1955
+ const t = String(q ?? "").toLowerCase();
1956
+ const dark = /\bdark\b|\bmidnight\b|\bnoir\b|\bblack\b|\bmoody\b|\bcinematic\b/.test(t);
1957
+ const light = /\blight\b|\bbright\b|\bairy\b|\bwhite\b|\bpastel\b/.test(t);
1958
+ if (dark && !light) return "dark";
1959
+ if (light && !dark) return "light";
1960
+ return null;
1961
+ }
1962
+ // Design-vocabulary aliases: "glassy" should find glassmorphism-tagged sites,
1963
+ // "3d" should find webgl/three.js ones. Aliases only ADD candidates — the
1964
+ // reason string always names the original query word, never the alias.
1965
+ const AWARD_ALIASES = {
1966
+ glassy: ["glass", "glassmorphism"], glass: ["glassmorphism"], neon: ["glow", "fluorescent"],
1967
+ "3d": ["webgl", "three", "threejs", "3-d"], minimal: ["minimalist", "minimalism"],
1968
+ bold: ["brutalist", "brutalism"], playful: ["fun", "quirky"], luxury: ["luxe", "premium", "elegant"],
1969
+ dark: ["black", "noir", "midnight", "moody"], light: ["white", "airy", "bright", "pastel"],
1970
+ };
1971
+ function awardExpandToken(tok) {
1972
+ const t = String(tok).toLowerCase();
1973
+ return [t, ...(AWARD_ALIASES[t] || [])];
1974
+ }
1975
+ function awardStemLite(tok) {
1976
+ let t = String(tok).toLowerCase();
1977
+ if (t.length > 5 && t.endsWith("ing")) t = t.slice(0, -3);
1978
+ else if (t.length > 4 && t.endsWith("ies")) t = t.slice(0, -3) + "y";
1979
+ else if (t.length > 4 && t.endsWith("es") && /(ses|xes|zes|ches|shes)$/.test(t)) t = t.slice(0, -2);
1980
+ else if (t.length > 3 && t.endsWith("s")) t = t.slice(0, -1);
1981
+ return t;
1982
+ }
1983
+ function awardFieldHay(site) {
1984
+ return {
1985
+ title: String(site.title || "").toLowerCase(),
1986
+ tags: (Array.isArray(site.tags) ? site.tags : []).map((t) => String(t).toLowerCase()),
1987
+ awards: (Array.isArray(site.awards) ? site.awards : []).map((t) => String(t).toLowerCase()),
1988
+ studio: String(site.studio || "").toLowerCase(),
1989
+ };
1990
+ }
1991
+ function awardScoreSites(sites, query, { technology, color } = {}) {
1992
+ const toks = awardTokens(query).map(awardStemLite);
1993
+ const n = Math.max(1, sites.length);
1994
+ const df = new Map();
1995
+ for (const t of toks) {
1996
+ let c = 0;
1997
+ for (const s of sites) {
1998
+ const h = awardFieldHay(s);
1999
+ const hay = (h.title + " " + h.tags.join(" ") + " " + h.studio).toLowerCase();
2000
+ if (hay.includes(t)) c++;
2001
+ }
2002
+ df.set(t, c);
2003
+ }
2004
+ const scored = (sites || []).map((s) => {
2005
+ const h = awardFieldHay(s);
2006
+ let score = 0;
2007
+ const reasons = [];
2008
+ const hint = awardThemeHint(query);
2009
+ for (const raw of awardTokens(query)) {
2010
+ const variants = awardExpandToken(awardStemLite(raw));
2011
+ const idf = Math.log(1 + n / (1 + (df.get(awardStemLite(raw)) || 0)));
2012
+ const titleHit = variants.some((v) => h.title.toLowerCase().includes(v));
2013
+ const tagHit = variants.some((v) => h.tags.some((g) => g === v || g.startsWith(v) || awardStemLite(g) === v));
2014
+ if (titleHit) { score += 3 * idf; reasons.push("title:" + raw); }
2015
+ else if (tagHit) { score += 2 * idf; reasons.push("tag:" + raw); }
2016
+ else if (variants.some((v) => (h.title + " " + h.tags.join(" ")).toLowerCase().includes(v))) { score += 1 * idf; reasons.push("text:" + raw); }
2017
+ else if (h.studio.includes(raw)) { score += 1.2 * idf; reasons.push("studio:" + raw); }
2018
+ }
2019
+ // Theme-hint boost (dark/light): tag-level signal, labeled as such.
2020
+ if (hint && (h.tags.includes(hint) || h.tags.some((g) => (AWARD_ALIASES[hint] || []).includes(g)))) {
2021
+ if (reasons.length > 0 || !toks.length) { score += 1.2; reasons.push("theme:" + hint); }
2022
+ }
2023
+ // Award/filter bonuses are tie-breakers for matched rows only: they must
2024
+ // never rescue a zero-token row into a "match" (honest-empty stays honest).
2025
+ const matched = reasons.length > 0 || !toks.length;
2026
+ if (technology && h.tags.includes(String(technology).toLowerCase()) && matched) { score += 1.5; reasons.push("tech-filter"); }
2027
+ if (color && JSON.stringify(s).toLowerCase().includes(String(color).toLowerCase()) && matched) { score += 0.5; reasons.push("color-hint"); }
2028
+ if (Array.isArray(s.awards) && s.awards.length && matched) score += 0.3;
2029
+ return { site: s, score: Math.round(score * 100) / 100, reasons: [...new Set(reasons)].slice(0, 6) };
2030
+ });
2031
+ scored.sort((a, b) => (b.score - a.score) || String(a.site.title).localeCompare(String(b.site.title)) || String(a.site.slug).localeCompare(String(b.site.slug)));
2032
+ return { tokens: awardTokens(query), scored };
2033
+ }
2034
+ function awardSuggestQueries(query, taxonomy) {
2035
+ const toks = awardTokens(query);
2036
+ const out = [];
2037
+ if (toks.length > 1) out.push(toks.slice(0, Math.min(3, toks.length)).join(" "));
2038
+ for (const t of toks.slice(0, 3)) out.push(t);
2039
+ const filters = (taxonomy && taxonomy.filters) || [];
2040
+ for (const t of toks) {
2041
+ const hit = filters.find((f) => f.startsWith(t.slice(0, 4)));
2042
+ if (hit && !out.includes(hit)) out.push(hit);
2043
+ }
2044
+ for (const fb of ["portfolio", "hero", "minimal"]) if (!out.includes(fb) && out.length < 5) out.push(fb);
2045
+ return [...new Set(out)].slice(0, 5);
2046
+ }
2047
+ function awardCompactSite(s, scoreInfo) {
2048
+ const tags = (s.tags || []).slice(0, 8);
2049
+ // try_for: agent-actionable cue derived ONLY from observed tags
2050
+ // ("dark hero sections" from tags [dark, hero]) — absent when no tags.
2051
+ const tryFor = tags.length ? "use for: " + tags.slice(0, 3).join(" ") + " sections" : null;
2052
+ return {
2053
+ slug: s.slug, title: s.title,
2054
+ sourceUrl: s.sourceUrl || awardSiteUrl(s.slug),
2055
+ liveUrl: s.liveUrl || null,
2056
+ tags,
2057
+ awards: s.awards || [],
2058
+ studio: s.studio || null,
2059
+ previewUrl: s.thumbnailPath ? awardThumbnailUrl(s.thumbnailPath, 880) : (s.ogImage || null),
2060
+ ...(tryFor ? { try_for: tryFor } : {}),
2061
+ ...(scoreInfo ? { relevance: scoreInfo.score, matchReasons: scoreInfo.reasons } : {}),
2062
+ evidence: { source: "awwwards", detailPath: s.detailPath || ("/sites/" + s.slug) },
2063
+ };
2064
+ }
2065
+ function awardFullSite(s, detail, scoreInfo) {
2066
+ const base = awardCompactSite(s, scoreInfo);
2067
+ return { ...base, description: (detail && detail.description) || null, palette: (detail && detail.palette) || [], technologies: (detail && detail.technologies) || [], elements: (detail && detail.elements) || [], awardHistory: (detail && detail.awards) || [], score: (detail && detail.score) ?? null, nominee: !!(detail && detail.nominee), retrievedAt: (detail && detail.retrievedAt) || null };
2068
+ }
2069
+
2070
+ // awardSiteIdeas: agent-usable design ideas derived ONLY from observed
2071
+ // detail fields. Every idea carries status:"suggested" + basis naming the
2072
+ // evidence — never presented as observed fact. Count contract: 0–6 ideas
2073
+ // depending on how much evidence the detail holds (1 idea on thin evidence
2074
+ // is valid, not a failure); null detail yields [].
2075
+ function awardSiteIdeas(detail) {
2076
+ if (!detail) return [];
2077
+ const ideas = [];
2078
+ const bands = new Set((detail.palette || []).map((c) => {
2079
+ const l = awardLuminance(String(c).toUpperCase());
2080
+ return l >= 0.7 ? "light" : l <= 0.3 ? "dark" : "mid";
2081
+ }));
2082
+ if (bands.has("dark") && !bands.has("light")) ideas.push({ idea: "near-black grounds with one saturated accent; keep body text light and verify 4.5:1", status: "suggested", basis: "observed palette skews dark" });
2083
+ if (bands.has("light") && !bands.has("dark")) ideas.push({ idea: "light grounds with dark ink; reserve saturated hues for CTAs and focus rings", status: "suggested", basis: "observed palette skews light" });
2084
+ const els = (detail.elements || []).join(" ").toLowerCase();
2085
+ if (/hero/.test(els)) ideas.push({ idea: "full-viewport hero with a single primary CTA above the fold", status: "suggested", basis: "observed element: hero" });
2086
+ if (/pricing|faq/.test(els)) ideas.push({ idea: "comparison table for pricing, accordion for faq — one pattern per section", status: "suggested", basis: "observed elements" });
2087
+ if (/testimonial|review/.test(els)) ideas.push({ idea: "social proof as a logo strip plus 2–3 short quotes, not a carousel", status: "suggested", basis: "observed elements" });
2088
+ const techs = (detail.technologies || []).join(" ").toLowerCase();
2089
+ if (/gsap|scrolltrigger/.test(techs)) ideas.push({ idea: "scroll reveals under 500ms with scrub only on pinned storytelling", status: "suggested", basis: "observed technology: gsap" });
2090
+ if (/webgl|three/.test(techs)) ideas.push({ idea: "keep WebGL to one hero canvas with a static-image fallback and reduced-motion off-switch", status: "suggested", basis: "observed technology: webgl" });
2091
+ return ideas.slice(0, 6);
2092
+ }
2093
+
2094
+ // ---- Polite fetch client (1 req/s, bounded, distinct errors) ----
2095
+ function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = AWARD_RATE_MS, userAgent = AWARD_USER_AGENT } = {}) {
2096
+ const fetchFn = fetchImpl || globalThis.fetch;
2097
+ if (typeof fetchFn !== "function") throw new Error("fetchImpl must be a function");
2098
+ let last = 0;
2099
+ let chain = Promise.resolve();
2100
+ const acquire = () => {
2101
+ const next = chain.then(async () => {
2102
+ const wait = last + rateMs - Date.now();
2103
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
2104
+ last = Date.now();
2105
+ });
2106
+ chain = next.catch(() => {});
2107
+ return next;
2108
+ };
2109
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2110
+ async function fetchOnce(url, label) {
2111
+ const ctrl = new AbortController();
2112
+ const timer = setTimeout(() => ctrl.abort(new Error("timeout after " + timeoutMs + "ms fetching " + label)), timeoutMs);
2113
+ try {
2114
+ const res = await fetchFn(url, { headers: { "user-agent": userAgent, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
2115
+ return res;
2116
+ } finally { clearTimeout(timer); }
2117
+ }
2118
+ async function readBounded(res, label) {
2119
+ const len = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : NaN;
2120
+ if (Number.isFinite(len) && len > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
2121
+ const text = await res.text();
2122
+ if (text && text.length > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
2123
+ return text || "";
2124
+ }
2125
+ async function getAwardHtml(pathOrUrl) {
2126
+ const path = awardValidateAwardPath(pathOrUrl);
2127
+ const startUrl = AWARD_BASE_URL + path;
2128
+ let url = startUrl;
2129
+ let lastErr = null;
2130
+ for (let attempt = 0; attempt < 2; attempt++) {
2131
+ await acquire();
2132
+ let res;
2133
+ try { res = await fetchOnce(url, "awwwards page"); }
2134
+ catch (e) {
2135
+ if (e && e.name === "AbortError") throw awardUpstream("timeout fetching " + url);
2136
+ lastErr = e;
2137
+ if (attempt < 1) { await sleep(300); continue; }
2138
+ throw awardUpstream("network error fetching " + url + ": " + String((e && e.message) || e).slice(0, 200));
2139
+ }
2140
+ if (res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308) {
2141
+ const loc = res.headers && res.headers.get ? res.headers.get("location") : null;
2142
+ if (!loc || attempt === 1) throw awardUpstream("redirect without location for " + url);
2143
+ try {
2144
+ const next = new URL(loc, url).toString();
2145
+ const nu = new URL(next);
2146
+ if (!AWARD_ALLOWED_HOSTS.includes(nu.hostname.toLowerCase())) throw awardUpstream("redirect leaves award hosts: " + nu.hostname);
2147
+ url = next;
2148
+ continue;
2149
+ } catch (e) { if (e && e.code === "UPSTREAM_ERROR") throw e; throw awardUpstream("bad redirect for " + url); }
2150
+ }
2151
+ if (res.status === 404) throw awardNotFound("awwwards page not found: " + url);
2152
+ if (res.status === 403 || res.status === 429) {
2153
+ const e = awardUpstream("awwwards is blocking requests (HTTP " + res.status + " on " + url + "). Try again later; blocks are never retried.");
2154
+ e.blocked = true;
2155
+ e.status = res.status;
2156
+ throw e;
2157
+ }
2158
+ if (res.status >= 500 && res.status < 600) {
2159
+ lastErr = awardUpstream("transient awwwards status " + res.status + " on " + url);
2160
+ if (attempt < 1) { await sleep(400); continue; }
2161
+ throw lastErr;
2162
+ }
2163
+ if (!res.ok) throw awardUpstream("awwwards HTTP " + res.status + " on " + url);
2164
+ return { html: await readBounded(res, "awwwards page"), finalUrl: url };
2165
+ }
2166
+ throw lastErr instanceof Error ? lastErr : awardUpstream("fetch failed for " + startUrl);
2167
+ }
2168
+ return { getAwardHtml };
2169
+ }
2170
+
2171
+ // awardStaticSummary: one-line human rendering of a static-motion record.
2172
+ // Lives with the data shape (locality); the blueprint handler just maps it.
2173
+ function awardStaticSummary(slug, st) {
2174
+ if (!st || st.status !== "static-markup") return null;
2175
+ return slug + ": " + [st.libs.length ? st.libs.join("+") : "vanilla", st.keyframes ? st.keyframes + " keyframes" : null, st.transitions ? st.transitions + " transitions" : null, st.canvases ? st.canvases + " canvas" : null, st.scrollCues.length ? "cues(" + st.scrollCues.slice(0, 4).join(",") + ")" : null].filter(Boolean).join(", ");
2176
+ }
2177
+
2178
+ // ---- Static motion cues: real markup evidence without a browser ----
2179
+ // Fetches a public reference URL (bounded, SSRF-guarded) and scans for
2180
+ // animation library scripts, @keyframes, transitions, scroll cues, and
2181
+ // media elements. Status is ALWAYS "static-markup" — this is observed page
2182
+ // source, never runtime behavior: ScrollTrigger counts, scrub ratios, and
2183
+ // easing vocabularies still require a recorded browser study. Failures
2184
+ // return {status:"unavailable", reason} instead of throwing (except empty
2185
+ // input, which is a contract violation → BAD_ARGS).
2186
+ const AWARD_STATIC_LIBS = [
2187
+ { lib: "gsap", res: [/gsap(\.min)?\.js/i, /scrolltrigger/i, /gsap\./i] },
2188
+ { lib: "lenis", res: [/lenis/i] },
2189
+ { lib: "locomotive", res: [/locomotive/i] },
2190
+ { lib: "swiper", res: [/swiper/i] },
2191
+ { lib: "framer", res: [/framer/i] },
2192
+ { lib: "lottie", res: [/lottie/i, /dotlottie/i] },
2193
+ { lib: "three", res: [/three(\.min)?\.js/i, /three\./i] },
2194
+ { lib: "pixi", res: [/pixi/i] },
2195
+ { lib: "aos", res: [/aos(\.min)?\.js/i, /data-aos/i] },
2196
+ { lib: "barba", res: [/barba/i] },
2197
+ ];
2198
+ const AWARD_STATIC_CUES = ["data-aos", "data-scroll", "scrolltrigger", "sticky", "parallax", "marquee", "reveal", "loader", "preloader"];
2199
+ async function awardStaticMotion(rawUrl, { fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false } = {}) {
2200
+ if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
2201
+ const fail = (reason) => ({ status: "unavailable", url: String(rawUrl).slice(0, 200), reason: String(reason).slice(0, 200) });
2202
+ let target;
2203
+ try { target = awardValidateHttpUrl(rawUrl, { allowLocal }); }
2204
+ catch (e) { return fail((e && e.message) || e); }
2205
+ const fetchFn = fetchImpl || globalThis.fetch;
2206
+ let html;
2207
+ try {
2208
+ const ctrl = new AbortController();
2209
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2210
+ try {
2211
+ const res = await fetchFn(target, { headers: { "user-agent": AWARD_USER_AGENT, accept: "text/html,*/*" }, redirect: "follow", signal: ctrl.signal });
2212
+ if (res.status === 404) return fail("HTTP 404 for " + target);
2213
+ if (!res.ok) return fail("HTTP " + res.status + " for " + target);
2214
+ html = await res.text();
2215
+ } finally { clearTimeout(timer); }
2216
+ } catch (e) { return fail(e && e.name === "AbortError" ? "timeout fetching " + target : "network error: " + String((e && e.message) || e).slice(0, 120)); }
2217
+ if (!html || !html.length) return fail("empty body for " + target);
2218
+ const src = html.slice(0, maxBytes);
2219
+ const count = (re) => Math.min(999, (src.match(re) || []).length);
2220
+ const libs = AWARD_STATIC_LIBS.filter(({ res }) => res.some((re) => re.test(src))).map(({ lib }) => lib);
2221
+ return {
2222
+ status: "static-markup", url: target,
2223
+ libs, keyframes: count(/@keyframes\s/gi), animations: count(/animation\s*:/gi), transitions: count(/transition\s*:/gi),
2224
+ canvases: count(/<canvas/gi), videos: count(/<video/gi),
2225
+ scrollCues: AWARD_STATIC_CUES.filter((c) => src.toLowerCase().includes(c)),
2226
+ reducedMotion: /prefers-reduced-motion/i.test(src),
2227
+ bytes: src.length,
2228
+ evidence: "static page markup (not runtime observation): record a browser study for trigger counts and easing vocab",
2229
+ fetchedAt: new Date().toISOString(),
2230
+ };
2231
+ }
2232
+
2233
+ // ---- Design-brief builder (pure): observed facts vs patterns vs proposals ----
2234
+ const AWARD_BLUEPRINT_VERSION = 1;
2235
+ function awardFreq(rows) {
2236
+ const by = new Map();
2237
+ for (const r of rows) for (const v of new Set((r.values || []).map((x) => String(x).toLowerCase()).filter(Boolean))) {
2238
+ if (!by.has(v)) by.set(v, new Set());
2239
+ by.get(v).add(r.slug);
2240
+ }
2241
+ return [...by].map(([value, slugs]) => ({ value, frequency: slugs.size, sourceSlugs: [...slugs].sort() }))
2242
+ .sort((a, b) => (b.frequency - a.frequency) || a.value.localeCompare(b.value));
2243
+ }
2244
+ function awardLuminance(hex) {
2245
+ try {
2246
+ const ch = hex.slice(1).match(/.{2}/g).map((p) => parseInt(p, 16) / 255).map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
2247
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
2248
+ } catch { return 0.5; }
2249
+ }
2250
+ // Technology families for compare: "WebGL" and "Three.js" are the same stack
2251
+ // choice spelled differently. Canonicalization applies ONLY to the family
2252
+ // overlap signal — per-site raw values stay verbatim.
2253
+ const AWARD_TECH_FAMILIES = [
2254
+ { family: "webgl", members: ["webgl", "three", "three.js", "threejs", "babylon", "pixi", "glsl", "shader"] },
2255
+ { family: "gsap", members: ["gsap", "greensock", "scrolltrigger"] },
2256
+ { family: "lenis", members: ["lenis", "studio-freight"] },
2257
+ { family: "react", members: ["react", "next", "next.js", "gatsby"] },
2258
+ { family: "tailwind", members: ["tailwind", "tailwindcss"] },
2259
+ ];
2260
+ function awardTechFamily(tech) {
2261
+ const t = String(tech || "").toLowerCase().trim();
2262
+ for (const f of AWARD_TECH_FAMILIES) if (f.family === t || f.members.includes(t)) return f.family;
2263
+ return t;
2264
+ }
2265
+ function awardCompareSites(entries) {
2266
+ // entries: [{ slug, detail|null, motion|null }]
2267
+ const usable = entries.filter((e) => e.detail);
2268
+ const tech = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.technologies || [] })));
2269
+ const techFamilies = awardFreq(usable.map((e) => ({ slug: e.slug, values: (e.detail.technologies || []).map(awardTechFamily) })));
2270
+ const elements = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.elements || [] })));
2271
+ const palette = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.palette || [] })));
2272
+ // Fuzzy element overlap: "Hero Section" and "hero banner" share the keyword
2273
+ // hero even when full strings differ — reported as keyword overlap, and the
2274
+ // exact strings stay visible per site so nothing is overstated.
2275
+ const elemKeywords = awardFreq(usable.map((e) => {
2276
+ const words = new Set();
2277
+ for (const el of (e.detail.elements || [])) for (const w of String(el).toLowerCase().split(/[^a-z0-9]+/)) if (w.length > 3) words.add(w);
2278
+ return { slug: e.slug, values: [...words] };
2279
+ }));
2280
+ const bandOf = (hex) => (awardLuminance(String(hex).toUpperCase()) >= 0.7 ? "light" : awardLuminance(String(hex).toUpperCase()) <= 0.3 ? "dark" : "mid");
2281
+ const bands = usable.map((e) => ({ slug: e.slug, bands: [...new Set((e.detail.palette || []).map(bandOf))] }));
2282
+ const bandVotes = {};
2283
+ for (const b of bands) for (const x of b.bands) bandVotes[x] = (bandVotes[x] || 0) + 1;
2284
+ const direction = [];
2285
+ const topBand = Object.entries(bandVotes).sort((a, b) => b[1] - a[1])[0];
2286
+ if (topBand && topBand[1] >= 2) direction.push("all refs skew " + topBand[0] + " — keep that value range for backgrounds");
2287
+ const sharedTech = tech.filter((t) => t.frequency >= 2);
2288
+ const sharedTechFamilies = techFamilies.filter((t) => t.frequency >= 2);
2289
+ if (sharedTech.length) direction.push("shared stack (" + sharedTech.slice(0, 3).map((t) => t.value).join(", ") + ") — safe default for the build");
2290
+ else if (sharedTechFamilies.length) direction.push("shared stack family (" + sharedTechFamilies.slice(0, 3).map((t) => t.value).join(", ") + ") — same capability, different spelling — safe default for the build");
2291
+ const per = entries.map((e) => ({
2292
+ slug: e.slug,
2293
+ title: (e.detail && e.detail.title) || e.slug,
2294
+ sourceUrl: (e.detail && e.detail.sourceUrl) || awardSiteUrl(e.slug),
2295
+ palette: (e.detail && e.detail.palette) || [],
2296
+ luminanceBands: (e.detail && [...new Set((e.detail.palette || []).map(bandOf))]) || [],
2297
+ technologies: (e.detail && e.detail.technologies) || [],
2298
+ technologiesInferred: !!(e.detail && e.detail.technologiesInferred),
2299
+ elements: (e.detail && e.detail.elements) || [],
2300
+ awards: (e.detail && e.detail.awards) || [],
2301
+ score: (e.detail && e.detail.score) ?? null,
2302
+ motion: e.motion ? { libs: e.motion.stack.libs, scrollModel: e.motion.stack.scrollModel } : null,
2303
+ gaps: [...(e.detail ? [] : ["detail unavailable"]), ...(e.motion ? [] : ["motion not recorded"])],
2304
+ }));
2305
+ return { per, sharedTech, sharedTechFamilies, sharedElements: elements.filter((t) => t.frequency >= 2), sharedElementKeywords: elemKeywords.filter((t) => t.frequency >= 2), sharedPalette: palette.filter((t) => t.frequency >= 2), sharedBands: bands, directionHints: direction };
2306
+ }
2307
+ function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
2308
+ if (!goal || !String(goal).trim()) throw awardBad("goal is required");
2309
+ const usable = (details || []).filter((d) => d && d.detail);
2310
+ const threshold = usable.length === 2 ? 2 : usable.length >= 3 ? Math.ceil(usable.length / 2) : Infinity;
2311
+ const tech = awardFreq(usable.map((d) => ({ slug: d.slug, values: d.detail.technologies || [] })));
2312
+ const elements = awardFreq(usable.map((d) => ({ slug: d.slug, values: d.detail.elements || [] })));
2313
+ const paletteRaw = awardFreq(usable.map((d) => ({ slug: d.slug, values: (d.detail.palette || []).map((c) => String(c).toUpperCase()) })));
2314
+ const palette = paletteRaw.map((p) => ({ ...p, luminanceBand: awardLuminance(p.value) >= 0.7 ? "light" : awardLuminance(p.value) <= 0.3 ? "dark" : "mid" })); const tagged = [...tech.map((s) => ({ ...s, kind: "technology" })), ...elements.map((s) => ({ ...s, kind: "element" })), ...palette.map((s) => ({ ...s, kind: "palette" }))];
2315
+ const shared = tagged.filter((s) => s.frequency >= threshold).sort((a, b) => (b.frequency - a.frequency) || a.value.localeCompare(b.value));
2316
+ const outliers = usable.length >= 2 ? tagged.filter((s) => s.frequency === 1).sort((a, b) => a.value.localeCompare(b.value)).slice(0, 12) : [];
2317
+ const gaps = [];
2318
+ for (const r of (refs || [])) {
2319
+ const hit = usable.find((d) => d.slug === r);
2320
+ if (!hit) gaps.push(r + ": reference detail unavailable (upstream fetch failed or slug unknown)");
2321
+ }
2322
+ for (const g of (extraGaps || [])) if (!gaps.includes(g)) gaps.push(g);
2323
+ if (!usable.length) gaps.push("no reference details available; brief is goal-only and low confidence");
2324
+ const sections = AWARD_SECTIONS;
2325
+ const lines = [];
2326
+ lines.push("# Design brief — " + String(goal).slice(0, 120));
2327
+ lines.push("");
2328
+ lines.push("_Goal:_ " + goal);
2329
+ if (technology) lines.push("_Stack:_ " + technology);
2330
+ if (visualDirection) lines.push("_Direction:_ " + visualDirection);
2331
+ if (responsive) lines.push("_Responsive:_ " + responsive);
2332
+ lines.push("");
2333
+ lines.push("## References (evidence)");
2334
+ for (const d of (details || [])) {
2335
+ const t = d.detail;
2336
+ lines.push("- `" + d.slug + "` — " + ((t && t.title) || d.slug) + " — " + ((t && t.sourceUrl) || awardSiteUrl(d.slug)) + (t ? "" : " (detail unavailable)"));
2337
+ }
2338
+ lines.push("");
2339
+ lines.push("## Direction");
2340
+ lines.push(shared.length ? "Build on what " + shared.length + " shared signals agree on; treat the outliers below as one-site flavor, not rules." : "No cross-reference consensus; follow the goal and the strongest single reference, and mark choices as proposed.");
2341
+ if (shared.length) { lines.push(""); lines.push("### Shared patterns (observed)"); for (const s of shared.slice(0, 12)) lines.push("- " + s.kind + " `" + s.value + "` — " + s.frequency + "/" + usable.length + " refs (" + s.sourceSlugs.join(", ") + ")"); }
2342
+ if (outliers.length) { lines.push(""); lines.push("### One-site details (do not generalize)"); for (const s of outliers.slice(0, 8)) lines.push("- " + s.kind + " `" + s.value + "` — only in " + s.sourceSlugs.join(", ")); }
2343
+ if (palette.length) { lines.push(""); lines.push("### Palette evidence"); for (const p of palette.slice(0, 8)) lines.push("- `" + p.value + "` (" + p.luminanceBand + ") — " + p.frequency + "/" + usable.length + " refs"); }
2344
+ let tokenGround = null, tokenInk = null, tokenAccents = [];
2345
+ if (requestedPalette.length) {
2346
+ lines.push("");
2347
+ lines.push("### Requested palette (proposed, not observed)");
2348
+ lines.push("Named in your goal/direction — apply as CSS tokens; verify contrast before shipping:");
2349
+ for (const c of requestedPalette) lines.push("- `" + c + "` (" + (awardLuminance(c) >= 0.7 ? "light" : awardLuminance(c) <= 0.3 ? "dark" : "mid") + ", proposed)");
2350
+ const observedBands = [...new Set(palette.map((p) => p.luminanceBand))];
2351
+ if (observedBands.length) lines.push("- Refs skew " + observedBands.join("/") + "; keep their layout rhythm, recolor to the proposed tokens above.");
2352
+ // Paste-ready starter: darkest requested hex = ground, lightest = text,
2353
+ // the rest = accents — all labeled proposed, contrast to be verified.
2354
+ const lum = requestedPalette.map((c) => ({ c, l: awardLuminance(c) })).sort((a, b) => a.l - b.l);
2355
+ let ground = lum[0].c, ink = lum[lum.length - 1].c, inkDefaulted = false;
2356
+ if (lum.length === 1) { ink = lum[0].l <= 0.3 ? "#F5F5F5" : "#111318"; inkDefaulted = true; }
2357
+ tokenGround = ground; tokenInk = ink; tokenAccents = lum.slice(1, -1).map((x) => x.c);
2358
+ const accents = lum.slice(1, -1).map((x) => x.c);
2359
+ lines.push("");
2360
+ lines.push("```css");
2361
+ lines.push("/* proposed tokens — paste into styles/tokens.css first */");
2362
+ lines.push(":root {");
2363
+ lines.push(" --aw-ground: " + ground + "; --aw-ink: " + ink + ";" + (inkDefaulted ? " /* ink defaulted for contrast — verify */" : ""));
2364
+ for (let i = 0; i < tokenAccents.length; i++) lines.push(" --aw-accent-" + (i + 1) + ": " + tokenAccents[i] + ";");
2365
+ lines.push(" --aw-glass: color-mix(in srgb, var(--aw-ink) 8%, transparent);");
2366
+ lines.push("}");
2367
+ lines.push("```");
2368
+ }
2369
+ lines.push("");
2370
+ lines.push("## Aura matches (free-only, real catalogue rows)");
2371
+ const matches = (auraMatches && auraMatches.length ? auraMatches : []);
2372
+ if (!matches.length) lines.push("- Catalogue lookup unavailable or matched nothing — proceed with tokens only; do not invent component IDs.");
2373
+ for (const m of matches.slice(0, 10)) lines.push("- [" + m.section + "] `" + m.componentTitle + "` (" + m.componentId + ") — " + m.pageUrl + " — " + m.why + (m.matchEvidence && m.matchEvidence.fallback ? " (via fallback " + m.matchEvidence.fallback + " — verify the fit before pasting)" : "") + (m.editorial ? " [editorial pick]" : ""));
2374
+ lines.push("");
2375
+ lines.push("## Sections (proposed order)");
2376
+ sections.forEach((s, i) => lines.push((i + 1) + ". " + s));
2377
+ lines.push("");
2378
+ lines.push("## Motion guidance (evidence-backed only)");
2379
+ lines.push(motionNotes || "No recorded motion for these refs; keep entrances <500ms, honor prefers-reduced-motion, add scroll reveals only if a study supports them.");
2380
+ if (structureNotes) lines.push("");
2381
+ if (structureNotes) lines.push("## Structure evidence");
2382
+ if (structureNotes) lines.push(structureNotes);
2383
+ lines.push("");
2384
+ lines.push("## Accessibility & performance");
2385
+ lines.push("- Keyboard: all interactive sections reachable; visible focus; no hover-only content.");
2386
+ lines.push("- Contrast: verify palette pairs at 4.5:1 before shipping.");
2387
+ lines.push("- Reduced motion: disable/parallax-off under prefers-reduced-motion; content stays readable without JS.");
2388
+ lines.push("- Performance: prefer system/Aura assets; lazy-load below-fold media; keep animation <500ms.");
2389
+ if (constraints.length) { lines.push(""); lines.push("## Constraints"); for (const c of constraints) lines.push("- Honor constraint: " + c); }
2390
+ lines.push("");
2391
+ lines.push("## Checklist");
2392
+ lines.push("- [ ] Tokens first (palette/type/spacing as CSS vars)");
2393
+ lines.push("- [ ] Sections in the order above, responsive at 390/768/1024+");
2394
+ lines.push("- [ ] Aura components pasted per match list, CSS scoped");
2395
+ lines.push("- [ ] Motion only from evidence; reduced-motion path tested");
2396
+ lines.push("- [ ] Contrast + keyboard pass; no copied third-party source");
2397
+ lines.push("");
2398
+ lines.push("## Evidence gaps");
2399
+ if (gaps.length) { for (const g of gaps) lines.push("- " + g); }
2400
+ else lines.push("- None material: refs, motion/structure evidence as cited above, catalogue matches listed.");
2401
+ // Evidence-scored confidence: base from usable ref count, +1 per
2402
+ // independent axis actually present (matched sections, recorded motion,
2403
+ // measured structure, fully-parsed tech). Thin evidence stays low;
2404
+ // rich evidence may reach high — same honesty, both directions.
2405
+ const why = [];
2406
+ let points = Math.min(usable.length, 3);
2407
+ why.push(usable.length + " usable ref" + (usable.length === 1 ? "" : "s"));
2408
+ const sectionHits = matches.filter((m) => m.section !== "tokens" && !m.editorial).length;
2409
+ if (sectionHits >= 4) { points++; why.push(sectionHits + " catalogue sections matched"); }
2410
+ if (motionNotes) { points++; why.push("recorded motion evidence"); }
2411
+ if (structureNotes && structureNotes.includes("(live-dom)")) { points++; why.push("measured structure"); }
2412
+ if (usable.length >= 2 && usable.every((d) => (d.detail.technologies || []).length && !d.detail.technologiesInferred)) { points++; why.push("fully-parsed technologies (not inferred)"); }
2413
+ const confidence = points <= 1 ? "low" : points <= 3 ? "low-medium" : points === 4 ? "medium" : "high";
2414
+ const spec = {
2415
+ version: AWARD_BLUEPRINT_VERSION, goal,
2416
+ refs: (refs || []).map((slug) => {
2417
+ const hit = (details || []).find((d) => d.slug === slug);
2418
+ const st = hit && hit.motion_static ? hit.motion_static.status : "none";
2419
+ return { slug, sourceUrl: awardSiteUrl(slug), motionStatic: st };
2420
+ }),
2421
+ sharedPatterns: shared, outliers: outliers.slice(0, 12), palette,
2422
+ requestedPalette: requestedPalette.map((c) => ({ value: c, status: "proposed" })),
2423
+ tokens_css: requestedPalette.length ? (":root{--aw-ground:" + tokenGround + ";--aw-ink:" + tokenInk + ";" + tokenAccents.map((c, i) => "--aw-accent-" + (i + 1) + ":" + c).join(";") + ";}") : "",
2424
+ auraMatches: matches.slice(0, 10),
2425
+ sections, constraints,
2426
+ confidence, confidenceWhy: why,
2427
+ gaps,
2428
+ };
2429
+ return { markdown: lines.join("\n"), spec };
2430
+ }
2431
+
2432
+ // ---- awards-index.mjs ----
2433
+ // Module: awards-index — persistent local award-reference store.
2434
+ // File-based JSON (no sqlite dep): atomic writes, versioned schema,
2435
+ // corrupt recovery, stale-lock single-writer, resume checkpoints, bounded
2436
+ // growth, winner baselines, watches, motion records. No auto-crawl on boot.
2437
+
2438
+ const AWARD_INDEX_VERSION = 1;
2439
+ const AWARD_INDEX_STALE_MS = 7 * 24 * 60 * 60 * 1000;
2440
+ const AWARD_LOCK_STALE_MS = 30 * 60 * 1000;
2441
+ const AWARD_MAX_SITES = 5000;
2442
+ const AWARD_MAX_ELEMENTS = 5000;
2443
+ const AWARD_MAX_MOTION = 200;
2444
+ const AWARD_FILES = ["sites.json", "elements.json", "taxonomy.json", "motion.json", "meta.json"];
2445
+
2446
+ function awardIdxBuiltins() {
2447
+ const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
2448
+ if (typeof g !== "function") throw awardErr("UNSUPPORTED_OPERATION", "file persistence needs Node 22+ (process.getBuiltinModule)");
2449
+ const fs = g.call(globalThis.process, "node:fs");
2450
+ const path = g.call(globalThis.process, "node:path");
2451
+ const os = g.call(globalThis.process, "node:os");
2452
+ return { fs, path, os };
2453
+ }
2454
+ function awardResolveDir(override) {
2455
+ const { path, os } = awardIdxBuiltins();
2456
+ if (override && String(override).trim()) {
2457
+ const p = String(override);
2458
+ if (p.includes("..")) throw awardErr("BAD_ARGS", "awards dir must not contain ..");
2459
+ return p;
2460
+ }
2461
+ const env = globalThis.process && globalThis.process.env ? globalThis.process.env.AURA_AWARDS_DIR : null;
2462
+ if (env && String(env).trim()) {
2463
+ if (String(env).includes("..")) throw awardErr("BAD_ARGS", "AURA_AWARDS_DIR must not contain ..");
2464
+ return String(env);
2465
+ }
2466
+ return path.join(os.homedir(), ".aura-mcp", "awards");
2467
+ }
2468
+ function awardIdxBad(m) { return awardErr("BAD_ARGS", m); }
2469
+
2470
+ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2471
+ const { fs, path } = awardIdxBuiltins();
2472
+ const root = awardResolveDir(dir);
2473
+ fs.mkdirSync(root, { recursive: true });
2474
+ fs.mkdirSync(path.join(root, "captures"), { recursive: true });
2475
+
2476
+ const fp = (name) => {
2477
+ if (!AWARD_FILES.includes(name) && name !== "lock.json") throw awardIdxBad("unknown index file: " + name);
2478
+ const full = path.join(root, name);
2479
+ if (path.relative(root, full).startsWith("..")) throw awardIdxBad("path traversal blocked");
2480
+ return full;
2481
+ };
2482
+ function readJson(name, fallback) {
2483
+ try {
2484
+ if (!fs.existsSync(fp(name))) return fallback;
2485
+ const raw = fs.readFileSync(fp(name), "utf8");
2486
+ const parsed = JSON.parse(raw);
2487
+ if (parsed && typeof parsed === "object" && parsed.version !== undefined && parsed.version !== AWARD_INDEX_VERSION && name !== "lock.json") {
2488
+ return { ...fallback, version: AWARD_INDEX_VERSION, migratedFrom: parsed.version, updatedAt: new Date(now()).toISOString() };
2489
+ }
2490
+ return parsed;
2491
+ } catch (e) {
2492
+ // Corrupt file: quarantine + safe empty (never throw away the session).
2493
+ try {
2494
+ const bad = fp(name) + ".corrupt-" + now();
2495
+ fs.renameSync(fp(name), bad);
2496
+ } catch { /* ignore */ }
2497
+ const err = awardErr("INDEX_CORRUPT", name + " was corrupt and was quarantined; index rebuilt empty (" + String((e && e.message) || e).slice(0, 120) + ")");
2498
+ return { ...fallback, corrupt: err.message, recoveredAt: new Date(now()).toISOString() };
2499
+ }
2500
+ }
2501
+ function writeAtomic(name, obj) {
2502
+ const full = fp(name);
2503
+ const tmp = full + ".tmp-" + now() + "-" + Math.floor(Math.random() * 1e6);
2504
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 1), "utf8");
2505
+ fs.renameSync(tmp, full);
2506
+ }
2507
+ const emptySites = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
2508
+ const emptyElements = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
2509
+ const emptyTax = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors: [], filters: [] });
2510
+ const emptyMotion = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
2511
+ const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [] });
2512
+
2513
+ function siteMap() {
2514
+ const doc = readJson("sites.json", emptySites());
2515
+ const map = new Map();
2516
+ for (const s of (doc.items || [])) if (s && s.slug) map.set(String(s.slug).toLowerCase(), s);
2517
+ return { doc, map };
2518
+ }
2519
+ function acquireLock() {
2520
+ const lockFile = path.join(root, "lock.json");
2521
+ try {
2522
+ if (fs.existsSync(lockFile)) {
2523
+ const prev = JSON.parse(fs.readFileSync(lockFile, "utf8"));
2524
+ if (prev && (now() - (prev.startedAt || 0)) < AWARD_LOCK_STALE_MS) {
2525
+ const e = awardErr("INDEX_BUSY", "another index run is in progress (lock held since " + new Date(prev.startedAt).toISOString() + ")");
2526
+ throw e;
2527
+ }
2528
+ }
2529
+ } catch (e) { if (e && e.code === "INDEX_BUSY") throw e; /* corrupt lock treated as stale */ }
2530
+ fs.writeFileSync(lockFile, JSON.stringify({ startedAt: now(), pid: (globalThis.process && globalThis.process.pid) || 0 }), "utf8");
2531
+ }
2532
+ function releaseLock() { try { fs.rmSync(path.join(root, "lock.json"), { force: true }); } catch { /* never mask */ } }
2533
+
2534
+ return {
2535
+ root,
2536
+ files: AWARD_FILES,
2537
+ getStatus() {
2538
+ const meta = readJson("meta.json", emptyMeta());
2539
+ const sites = readJson("sites.json", emptySites());
2540
+ const elements = readJson("elements.json", emptyElements());
2541
+ const motion = readJson("motion.json", emptyMotion());
2542
+ const tax = readJson("taxonomy.json", emptyTax());
2543
+ let lock = null;
2544
+ try {
2545
+ const lockFile = path.join(root, "lock.json");
2546
+ if (fs.existsSync(lockFile)) lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
2547
+ } catch { lock = { unreadable: true }; }
2548
+ const finishedAt = meta.indexStatus && meta.indexStatus.finishedAt;
2549
+ const freshness = finishedAt ? new Date(finishedAt).toISOString() : null;
2550
+ return {
2551
+ dir: root, version: AWARD_INDEX_VERSION,
2552
+ counts: { sites: (sites.items || []).length, elements: (elements.items || []).length, motion: (motion.items || []).length, taxonomyFilters: (tax.filters || []).length, taxonomyColors: (tax.colors || []).length, watches: (meta.watches || []).length },
2553
+ freshness, stale: !finishedAt || (now() - finishedAt >= AWARD_INDEX_STALE_MS),
2554
+ progress: meta.indexStatus || null, pendingPages: meta.progress || [],
2555
+ lock: lock ? { held: true, since: lock.startedAt ? new Date(lock.startedAt).toISOString() : null } : { held: false },
2556
+ ...(sites.corrupt ? { sitesCorrupt: sites.corrupt } : {}),
2557
+ ...(meta.corrupt ? { metaCorrupt: meta.corrupt } : {}),
2558
+ };
2559
+ },
2560
+ listSites(limit = 50) {
2561
+ const { map } = siteMap();
2562
+ return [...map.values()].slice(0, Math.max(1, Math.min(limit, 200)));
2563
+ },
2564
+ getSite(slug) {
2565
+ const n = awardNormalizeSlug(slug);
2566
+ const { map } = siteMap();
2567
+ return map.get(n) || null;
2568
+ },
2569
+ upsertSites(sites) {
2570
+ const { doc, map } = siteMap();
2571
+ let added = 0, updated = 0;
2572
+ for (const s of (sites || [])) {
2573
+ if (!s || !s.slug) continue;
2574
+ const k = String(s.slug).toLowerCase();
2575
+ if (map.has(k)) { map.set(k, { ...map.get(k), ...s }); updated++; }
2576
+ else { map.set(k, s); added++; }
2577
+ }
2578
+ let items = [...map.values()];
2579
+ if (items.length > AWARD_MAX_SITES) items = items.slice(items.length - AWARD_MAX_SITES);
2580
+ writeAtomic("sites.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items });
2581
+ return { added, updated, total: items.length };
2582
+ },
2583
+ getTaxonomy() { return readJson("taxonomy.json", emptyTax()); },
2584
+ setTaxonomy(tax) {
2585
+ const colors = Array.isArray(tax.colors) ? [...new Set(tax.colors.map(String))].slice(0, 100) : [];
2586
+ const filters = Array.isArray(tax.filters) ? [...new Set(tax.filters.map(String))].slice(0, 400) : [];
2587
+ writeAtomic("taxonomy.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors, filters });
2588
+ return { colors: colors.length, filters: filters.length };
2589
+ },
2590
+ upsertElements(items) {
2591
+ const doc = readJson("elements.json", emptyElements());
2592
+ const map = new Map((doc.items || []).map((e) => [String(e.slug), e]));
2593
+ let added = 0;
2594
+ for (const e of (items || [])) {
2595
+ if (!e || !e.slug) continue;
2596
+ if (!map.has(String(e.slug))) added++;
2597
+ map.set(String(e.slug), e);
2598
+ }
2599
+ let list = [...map.values()];
2600
+ if (list.length > AWARD_MAX_ELEMENTS) list = list.slice(list.length - AWARD_MAX_ELEMENTS);
2601
+ writeAtomic("elements.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: list });
2602
+ return { added, total: list.length };
2603
+ },
2604
+ listElements(limit = 50) {
2605
+ const doc = readJson("elements.json", emptyElements());
2606
+ return (doc.items || []).slice(0, Math.max(1, Math.min(limit, 200)));
2607
+ },
2608
+ getElement(slug) {
2609
+ const doc = readJson("elements.json", emptyElements());
2610
+ return (doc.items || []).find((e) => String(e.slug) === String(slug)) || null;
2611
+ },
2612
+ recordMotion(study) {
2613
+ if (!study || !study.url) throw awardIdxBad("motion study needs a url");
2614
+ const doc = readJson("motion.json", emptyMotion());
2615
+ const items = (doc.items || []).filter((m) => m.url !== study.url);
2616
+ items.unshift({ ...study, storedAt: new Date(now()).toISOString() });
2617
+ writeAtomic("motion.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: items.slice(0, AWARD_MAX_MOTION) });
2618
+ return study;
2619
+ },
2620
+ searchMotion({ library, easing, scrollModel, limit = 10 } = {}) {
2621
+ const doc = readJson("motion.json", emptyMotion());
2622
+ let items = (doc.items || []);
2623
+ if (library) items = items.filter((m) => JSON.stringify(m).toLowerCase().includes(String(library).toLowerCase()));
2624
+ if (easing) items = items.filter((m) => JSON.stringify(m.easingVocab || m.easings || []).toLowerCase().includes(String(easing).toLowerCase()));
2625
+ if (scrollModel) items = items.filter((m) => String((m.stack && m.stack.scrollModel) || m.scrollModel || "").toLowerCase() === String(scrollModel).toLowerCase());
2626
+ return { total: items.length, items: items.slice(0, Math.max(1, Math.min(limit || 10, 25))), empty: (doc.items || []).length === 0 };
2627
+ },
2628
+ // Index lifecycle: crawl tag pages via client, resumable, bounded.
2629
+ async rebuild({ client, tags, log = () => {} } = {}) {
2630
+ if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("rebuild needs an award client");
2631
+ acquireLock();
2632
+ const startedAt = now();
2633
+ try {
2634
+ let meta = readJson("meta.json", emptyMeta());
2635
+ let tax = readJson("taxonomy.json", emptyTax());
2636
+ if ((!tax.filters || !tax.filters.length)) {
2637
+ const { html } = await client.getAwardHtml("/websites/");
2638
+ tax = { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), ...awardParseCategories(html) };
2639
+ if (!(tax.filters || []).length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: parsed 0 categories");
2640
+ writeAtomic("taxonomy.json", tax);
2641
+ }
2642
+ const wanted = Array.isArray(tags) && tags.length ? tags.map(String).slice(0, 200) : (tax.filters || []).slice(0, 200);
2643
+ if (!wanted.length) throw awardErr("UPSTREAM_ERROR", "no taxonomy to crawl; index has no filters");
2644
+ const done = new Set(meta.progress || []);
2645
+ // Completed checkpoint from a prior cycle is not resumable work.
2646
+ if (meta.indexStatus && meta.indexStatus.finishedAt && done.size >= (meta.indexStatus.pagesTotal || 0)) { meta.progress = []; done.clear(); }
2647
+ let pagesDone = 0, sitesIndexed = 0;
2648
+ const skipped = [...wanted].filter((t) => done.has(t)).length;
2649
+ for (const tag of wanted) {
2650
+ if (done.has(tag)) continue;
2651
+ const { html } = await client.getAwardHtml("/websites/" + encodeURIComponent(tag) + "/");
2652
+ const sites = awardParseListing(html);
2653
+ if (!sites.length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: 0 cards on /websites/" + tag + "/");
2654
+ this.upsertSites(sites.map((s) => ({ ...s, tags: [...new Set([...(s.tags || []), tag])] })));
2655
+ sitesIndexed += sites.length;
2656
+ pagesDone++;
2657
+ done.add(tag);
2658
+ meta = readJson("meta.json", emptyMeta());
2659
+ meta.progress = [...done];
2660
+ meta.updatedAt = new Date(now()).toISOString();
2661
+ writeAtomic("meta.json", meta);
2662
+ log("[" + done.size + "/" + wanted.length + "] " + tag + ": " + sites.length + " sites");
2663
+ }
2664
+ meta = readJson("meta.json", emptyMeta());
2665
+ meta.indexStatus = { startedAt, finishedAt: now(), pagesDone: done.size, pagesTotal: wanted.length, sitesIndexed };
2666
+ meta.progress = [];
2667
+ meta.updatedAt = new Date(now()).toISOString();
2668
+ writeAtomic("meta.json", meta);
2669
+ return { pagesDone, pagesTotal: wanted.length, sitesIndexed, skipped };
2670
+ } catch (e) {
2671
+ try {
2672
+ const meta = readJson("meta.json", emptyMeta());
2673
+ meta.indexStatus = { ...(meta.indexStatus || {}), lastError: String((e && e.message) || e).slice(0, 300) };
2674
+ writeAtomic("meta.json", meta);
2675
+ } catch { /* never mask */ }
2676
+ throw e;
2677
+ } finally { releaseLock(); }
2678
+ },
2679
+ async resume(args) { return this.rebuild(args); },
2680
+ async refresh(args) {
2681
+ const meta = readJson("meta.json", emptyMeta());
2682
+ meta.progress = [];
2683
+ writeAtomic("meta.json", meta);
2684
+ return this.rebuild(args);
2685
+ },
2686
+ // Winners: baseline seed then delta (never fabricate dates).
2687
+ async newWinners({ client, award = "sotd" } = {}) {
2688
+ if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("newWinners needs an award client");
2689
+ if (!AWARD_FILTERS[award]) throw awardIdxBad("award must be sotd|developer|honorable");
2690
+ const { html } = await client.getAwardHtml("/" + AWARD_FILTERS[award] + "/");
2691
+ const parsed = awardParseListing(html);
2692
+ const meta = readJson("meta.json", emptyMeta());
2693
+ const key = "baseline_" + award;
2694
+ const prev = meta.baselines && meta.baselines[key];
2695
+ const day = new Date(now()).toISOString().slice(0, 10);
2696
+ if (!prev) {
2697
+ meta.baselines = { ...(meta.baselines || {}), [key]: { slugs: parsed.map((s) => s.slug), at: new Date(now()).toISOString() } };
2698
+ writeAtomic("meta.json", meta);
2699
+ this.upsertSites(parsed);
2700
+ return { day, seeded: true, newWinners: [], totalInListing: parsed.length, note: "baseline seeded; no historical delta available yet" };
2701
+ }
2702
+ const seen = new Set(prev.slugs || []);
2703
+ const fresh = parsed.filter((s) => !seen.has(s.slug));
2704
+ meta.baselines[key] = { slugs: parsed.map((s) => s.slug), at: new Date(now()).toISOString() };
2705
+ writeAtomic("meta.json", meta);
2706
+ if (parsed.length) this.upsertSites(parsed);
2707
+ return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length };
2708
+ },
2709
+ watchAdd({ kind, pattern, award } = {}) {
2710
+ if (!["studio", "tag", "url"].includes(kind)) throw awardIdxBad("watch kind must be studio|tag|url");
2711
+ if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 120) throw awardIdxBad("watch pattern must be 1-120 chars");
2712
+ const clean = pattern.trim().toLowerCase();
2713
+ if (kind === "url") awardNormalizeSlug(clean);
2714
+ if (kind === "tag" && !/^[a-z0-9-]{2,60}$/.test(clean)) throw awardIdxBad("tag watch must be a slug like 'webgl'");
2715
+ if (award !== undefined && !["sotd", "developer", "honorable"].includes(award)) throw awardIdxBad("watch award must be sotd|developer|honorable");
2716
+ const meta = readJson("meta.json", emptyMeta());
2717
+ const watches = meta.watches || [];
2718
+ if (watches.some((w) => w.kind === kind && w.pattern === clean)) throw awardIdxBad("duplicate watch: " + kind + ":" + clean);
2719
+ if (watches.length >= 50) throw awardIdxBad("too many watches (max 50)");
2720
+ const rec = { kind, pattern: clean, award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
2721
+ meta.watches = [...watches, rec];
2722
+ writeAtomic("meta.json", meta);
2723
+ return rec;
2724
+ },
2725
+ watchList() {
2726
+ const meta = readJson("meta.json", emptyMeta());
2727
+ return meta.watches || [];
2728
+ },
2729
+ watchRemove({ kind, pattern } = {}) {
2730
+ const meta = readJson("meta.json", emptyMeta());
2731
+ const clean = String(pattern || "").trim().toLowerCase();
2732
+ const before = (meta.watches || []).length;
2733
+ meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && w.pattern === clean));
2734
+ writeAtomic("meta.json", meta);
2735
+ return { removed: (meta.watches.length < before) };
2736
+ },
2737
+ checkWatches() {
2738
+ const meta = readJson("meta.json", emptyMeta());
2739
+ const { map } = siteMap();
2740
+ const rows = [...map.values()];
2741
+ const out = [];
2742
+ const watches = (meta.watches || []).map((w) => ({ ...w }));
2743
+ for (const w of watches) {
2744
+ const hits = rows.filter((s) => {
2745
+ if (w.kind === "url") return String(s.slug) === w.pattern;
2746
+ if (w.kind === "studio") return String(s.studio || "").toLowerCase().includes(w.pattern);
2747
+ return (s.tags || []).some((t) => String(t).toLowerCase().replace(/\W+/g, "-") === w.pattern);
2748
+ });
2749
+ const slugs = hits.map((s) => s.slug).sort();
2750
+ if (w.lastSlugs === null || w.lastSlugs === undefined) {
2751
+ w.lastCheckedAt = new Date(now()).toISOString();
2752
+ w.lastSlugs = slugs;
2753
+ continue;
2754
+ }
2755
+ const prev = new Set(w.lastSlugs || []);
2756
+ const fresh = hits.filter((s) => !prev.has(s.slug));
2757
+ w.lastCheckedAt = new Date(now()).toISOString();
2758
+ w.lastSlugs = slugs;
2759
+ if (fresh.length) out.push({ watch: { kind: w.kind, pattern: w.pattern }, matches: fresh.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
2760
+ }
2761
+ meta.watches = watches;
2762
+ writeAtomic("meta.json", meta);
2763
+ return out;
2764
+ },
2765
+ };
2766
+ }
2767
+
2768
+ // ---- browser-cdp.mjs ----
2769
+ // Module: browser-cdp — optional native Chrome DevTools Protocol adapter.
2770
+ // Zero npm deps: finds system Chrome/Chromium, launches an isolated profile,
2771
+ // drives it over global WebSocket CDP (navigate, viewport, evaluate,
2772
+ // screenshot), cleans up. Missing browser => BROWSER_UNAVAILABLE, never fake.
2773
+
2774
+ const CDP_VIEWPORTS = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true } };
2775
+ const CDP_CAPTURE_TIMEOUT_MS = 45000;
2776
+ const CDP_NAV_TIMEOUT_MS = 30000;
2777
+ const CDP_MAX_SETTLE_MS = 8000;
2778
+ const CDP_MAX_FRAMES = 16;
2779
+ const CDP_MAX_RECORD_MS = 30000;
2780
+
2781
+ function cdpBuiltins() {
2782
+ const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
2783
+ if (typeof g !== "function") throw awardErr("UNSUPPORTED_OPERATION", "browser capture needs Node 22+");
2784
+ return {
2785
+ fs: g.call(globalThis.process, "node:fs"),
2786
+ path: g.call(globalThis.process, "node:path"),
2787
+ os: g.call(globalThis.process, "node:os"),
2788
+ cp: g.call(globalThis.process, "node:child_process"),
2789
+ crypto: g.call(globalThis.process, "node:crypto"),
2790
+ };
2791
+ }
2792
+ function cdpBad(m) { return awardErr("BAD_ARGS", m); }
2793
+ function cdpUnavailable(detail) {
2794
+ const e = awardErr("BROWSER_UNAVAILABLE", "no Chrome/Chromium found. Install Chrome or set AURA_CHROME_PATH to its executable. " + (detail || ""));
2795
+ return e;
2796
+ }
2797
+ // Normalize ANY CDP setup failure (socket refused, attach hang, first-frame
2798
+ // timeout) to BROWSER_UNAVAILABLE with operation-specific fallback guidance.
2799
+ // A hung Target.* handshake must never surface as a raw upstream timeout.
2800
+ function cdpSetupFailed(op, err) {
2801
+ const why = String((err && err.message) || err).slice(0, 180);
2802
+ const fallback = op === "structure"
2803
+ ? " Fallback: aura_visual_audit {mode:structure} retries automatically over static HTML (source:static-html, no measured offsets)."
2804
+ : op === "motion"
2805
+ ? " Fallback motion plan (generic, NOT observed — verify against the live site when a browser is available): entrances <500ms ease-out, one idea per viewport; scroll reveals via IntersectionObserver or GSAP ScrollTrigger (scrub only for pinned storytelling); hover lifts 1.03 + shadow; keep easing vocabulary to ease-out/cubic-bezier(.22,1,.36,1); honor prefers-reduced-motion (disable parallax/scrub, keep content readable); record a study later with aura_motion_study {mode:record}."
2806
+ : " Fallback: aura_visual_audit {mode:structure} can still estimate sections from static HTML; screenshots need a working browser.";
2807
+ const e = awardErr("BROWSER_UNAVAILABLE", "browser control failed during " + op + " (" + why + "). Check that Chrome is installed and no firewall blocks 127.0.0.1 remote debugging." + fallback);
2808
+ return e;
2809
+ }
2810
+ function cdpResolveViewport(name) {
2811
+ if (name === undefined || name === null) return CDP_VIEWPORTS.desktop;
2812
+ if (!CDP_VIEWPORTS[name]) throw cdpBad("viewport must be desktop|mobile");
2813
+ return CDP_VIEWPORTS[name];
2814
+ }
2815
+ // Self-contained page snippets (no closures; run via Runtime.evaluate).
2816
+ const CDP_SCAN_JS = `(() => { const doc = document; const out = { title: doc.title, totalHeight: Math.round(doc.documentElement.scrollHeight), candidates: [] }; const bodyW = (doc.body.getBoundingClientRect().width || 1); const push = (tag, label, bg, top, height, textStart) => out.candidates.push({ tag, label, bg, top: Math.round(top), height: Math.round(height), textStart }); push("body", "body", getComputedStyle(doc.body).backgroundColor, 0, out.totalHeight, ""); const seen = new Set(); const visit = (el, depth) => { if (depth > 6 || out.candidates.length >= 300 || seen.has(el)) return; seen.add(el); try { const r = el.getBoundingClientRect(); if (el !== doc.body && r.width >= 0.6 * bodyW && r.height >= 120) { const bg = getComputedStyle(el).backgroundColor || ""; const m = /rgba?\\(([^)]+)\\)/.exec(bg); let a = 0; if (m) { const parts = m[1].replace(/\\//g, " ").trim().split(/[\\s,]+/).filter(Boolean); const raw = parts.length >= 4 ? parts[3] : "1"; a = raw.endsWith("%") ? parseFloat(raw) / 100 : parseFloat(raw); if (Number.isNaN(a)) a = 0; } else if (/^(oklch|oklab|lab|lch|hwb|color)\\(/.test(bg.trim())) a = 1; if (a > 0) push(el.tagName.toLowerCase(), (el.id ? "#" + el.id : "") + (el.classList.length ? "." + el.classList[0] : ""), bg, r.top + window.scrollY, r.height, (el.textContent || "").trim().slice(0, 60)); } } catch {} for (const ch of el.children) visit(ch, depth + 1); }; visit(doc.body, 0); return out; })()`;
2817
+ const CDP_MOTION_JS = `(() => { const g = window; const libs = []; const add = (s) => { if (!libs.includes(s)) libs.push(s); }; try { if (g.gsap || g.TweenMax) add("gsap"); if (g.ScrollTrigger && g.ScrollTrigger.getAll) add("scrolltrigger"); if (g.Lenis || (document.documentElement.classList && document.documentElement.classList.contains("lenis"))) add("lenis"); if (document.querySelector("[data-scroll-container]")) add("locomotive"); if (document.querySelector("[data-aos]")) add("aos"); if (g.Swiper || document.querySelector(".swiper")) add("swiper"); } catch {} const render = []; try { const cs = [...document.querySelectorAll("canvas")]; if (g.THREE) render.push("three"); if (g.PIXI) render.push("pixi"); if (cs.length && !render.length) render.push("webgl"); } catch {} let st = { count: 0, scrub: 0, pin: 0, sample: [] }; try { if (g.ScrollTrigger && g.ScrollTrigger.getAll) { const all = g.ScrollTrigger.getAll(); st.count = all.length; for (const s of all.slice(0, 20)) { const v = (s.vars || {}); if (v.scrub) st.scrub++; if (v.pin) st.pin++; st.sample.push({ start: v.start != null ? String(v.start) : null, end: v.end != null ? String(v.end) : null, scrub: !!v.scrub, pin: !!v.pin, duration: null, ease: null }); } } } catch {} const tweens = { durations: [], eases: [] }; try { const kids = (g.gsap && g.gsap.globalTimeline && g.gsap.globalTimeline.getChildren(true, true, true)) || []; for (const k of kids.slice(0, 100)) { try { const d = typeof k.duration === "function" ? k.duration() : k.vars && k.vars.duration; if (typeof d === "number") tweens.durations.push(d); if (k.vars && k.vars.ease) tweens.eases.push(String(k.vars.ease)); } catch {} } } catch {} let css = []; try { const anims = (document.getAnimations && document.getAnimations()) || []; for (const a of anims.slice(0, 200)) { try { const t = a.effect && a.effect.getComputedTiming && a.effect.getComputedTiming(); if (t && t.easing && String(t.easing).includes("bezier")) css.push(String(t.easing)); if (t && typeof t.duration === "number") tweens.durations.push(t.duration / 1000); } catch {} } } catch {} return { libs, render, scrollTrigger: st, tweens, cssBeziers: css }; })()`;
2818
+
2819
+ // Pure band reducer (mirrors upstream sweep; tested with fixture DOM).
2820
+ function cdpCollapseBands(cands, totalHeight, maxBands = 40) {
2821
+ const usable = (cands || []).map((c) => ({ ...c, top: Math.round(c.top || 0), height: Math.round(c.height || 0) })).filter((c) => c.height > 0);
2822
+ const STEP = 8;
2823
+ const bgAt = (y) => {
2824
+ let best = null;
2825
+ for (const c of usable) if (c.top <= y && y < c.top + c.height && (!best || c.height < best.height)) best = c;
2826
+ return best;
2827
+ };
2828
+ const runs = [];
2829
+ let y = 0, cur = null;
2830
+ while (y < totalHeight) {
2831
+ const c = bgAt(y);
2832
+ if (cur && c && c.bg === cur.cand.bg) { /* continue */ }
2833
+ else {
2834
+ if (cur) runs.push({ cand: cur.cand, top: cur.top, height: y - cur.top });
2835
+ cur = c ? { cand: c, top: y } : null;
2836
+ }
2837
+ y += STEP;
2838
+ }
2839
+ if (cur) runs.push({ cand: cur.cand, top: cur.top, height: totalHeight - cur.top });
2840
+ const merged = [];
2841
+ for (const r of runs) {
2842
+ const prev = merged[merged.length - 1];
2843
+ if (prev && !prev.sealed && prev.cand.bg === r.cand.bg) prev.height = r.top + r.height - prev.top;
2844
+ else if (r.height < 40 && prev) { prev.height = r.top + r.height - prev.top; prev.sealed = true; }
2845
+ else merged.push({ ...r });
2846
+ }
2847
+ while (merged.length > maxBands && merged.length > 1) {
2848
+ let idx = 1;
2849
+ for (let i = 1; i < merged.length; i++) if (merged[i].height < merged[idx].height) idx = i;
2850
+ merged[idx - 1].height += merged[idx].height;
2851
+ merged.splice(idx, 1);
2852
+ }
2853
+ return merged.map((r, i) => ({ index: i, tag: r.cand.tag, label: r.cand.label, background: r.cand.bg, offsetTop: Math.round(r.top), height: Math.round(r.height), textStart: r.cand.textStart || "" }));
2854
+ }
2855
+ // Static-HTML fallback bands (no layout): honest estimated source label.
2856
+ function cdpBandsFromHtml(html, url) {
2857
+ const src = String(html || "");
2858
+ const bands = [];
2859
+ const re = /<(section|header|footer|nav|main|div|article)[^>]*>/gi;
2860
+ let m, i = 0;
2861
+ while ((m = re.exec(src)) && bands.length < 40) {
2862
+ const tag = m[1].toLowerCase();
2863
+ const chunk = src.slice(m.index, m.index + 2000);
2864
+ const h = (chunk.match(/<h[1-3][^>]*>([^<]{1,80})/i) || [])[1];
2865
+ const bg = (m[0].match(/background(?:-color)?:\s*([^;"']{1,30})/i) || [])[1] || null;
2866
+ const hasImg = /<img|<video/i.test(chunk);
2867
+ bands.push({ index: i++, tag, label: tag, background: bg, offsetTop: null, height: null, textStart: h ? String(h).trim().slice(0, 60) : "", media: hasImg });
2868
+ }
2869
+ return { url, title: (src.match(/<title>([^<]{1,120})<\/title>/i) || [])[1] || url, totalHeight: null, bands, source: "static-html", note: "offsets/heights unavailable without layout; use capture/structure via browser for measured bands" };
2870
+ }
2871
+
2872
+ // CDP connection: id-keyed commands over a WebSocket-like transport.
2873
+ // Messages without an id are domain events (Page.loadEventFired, ...)
2874
+ // dispatched to onEvent subscribers.
2875
+ function createCdpConn(socket) {
2876
+ let nextId = 1;
2877
+ const pending = new Map();
2878
+ const listeners = new Map();
2879
+ const route = (raw) => {
2880
+ let msg;
2881
+ try { msg = JSON.parse(typeof raw === "string" ? raw : raw.data); } catch { return; }
2882
+ if (msg && msg.id !== undefined && pending.has(msg.id)) {
2883
+ const { resolve, reject } = pending.get(msg.id);
2884
+ pending.delete(msg.id);
2885
+ if (msg.error) reject(awardErr("UPSTREAM_ERROR", "CDP " + (msg.error.message || "error")));
2886
+ else resolve(msg.result);
2887
+ } else if (msg && typeof msg.method === "string" && listeners.has(msg.method)) {
2888
+ for (const cb of listeners.get(msg.method)) { try { cb(msg.params || {}); } catch { /* listener never breaks routing */ } }
2889
+ }
2890
+ };
2891
+ // Always install (never guard on a read: setter-only transports report
2892
+ // onmessage as undefined on read, which previously left responses unrouted
2893
+ // so EVERY command timed out even against a healthy browser).
2894
+ try { socket.onmessage = (evt) => route(typeof evt === "string" ? evt : evt.data); } catch { /* send-only test doubles */ }
2895
+ return {
2896
+ pendingCount: () => pending.size,
2897
+ onEvent: (method, cb) => {
2898
+ if (!listeners.has(method)) listeners.set(method, new Set());
2899
+ listeners.get(method).add(cb);
2900
+ return () => { try { listeners.get(method).delete(cb); } catch { /* ignore */ } };
2901
+ },
2902
+ command(method, params = {}, timeoutMs = 10000) {
2903
+ const id = nextId++;
2904
+ return new Promise((resolve, reject) => {
2905
+ const timer = setTimeout(() => { pending.delete(id); reject(awardErr("CAPTURE_TIMEOUT", "CDP " + method + " timed out")); }, timeoutMs);
2906
+ pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
2907
+ try { socket.send(JSON.stringify({ id, method, params })); }
2908
+ catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
2909
+ });
2910
+ },
2911
+ // Test seam: deliver an inbound message (out-of-order safe).
2912
+ deliver(msg) {
2913
+ if (msg && msg.id !== undefined && pending.has(msg.id)) {
2914
+ const { resolve, reject } = pending.get(msg.id);
2915
+ pending.delete(msg.id);
2916
+ if (msg.error) reject(awardErr("UPSTREAM_ERROR", "CDP error"));
2917
+ else resolve(msg.result);
2918
+ } else if (msg && typeof msg.method === "string" && listeners.has(msg.method)) {
2919
+ for (const cb of listeners.get(msg.method)) { try { cb(msg.params || {}); } catch { /* ignore */ } }
2920
+ }
2921
+ },
2922
+ };
2923
+ }
2924
+
2925
+ function findChromeExecutable({ envPath, exists = null, platform = null } = {}) {
2926
+ const { fs } = cdpBuiltins();
2927
+ const has = exists || ((p) => { try { return fs.existsSync(p); } catch { return false; } });
2928
+ const env = globalThis.process && globalThis.process.env ? globalThis.process.env : {};
2929
+ const plat = platform || (globalThis.process ? globalThis.process.platform : "linux");
2930
+ const candidates = [];
2931
+ const configured = envPath || env.AURA_CHROME_PATH;
2932
+ if (configured) candidates.push(configured);
2933
+ if (plat === "win32") {
2934
+ candidates.push("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Chromium\\Application\\chrome.exe");
2935
+ const pf = env["PROGRAMFILES"] || "C:\\Program Files";
2936
+ candidates.push(pf + "\\Google\\Chrome\\Application\\chrome.exe");
2937
+ } else if (plat === "darwin") {
2938
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium");
2939
+ } else {
2940
+ candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium");
2941
+ }
2942
+ for (const c of candidates) if (c && has(c)) return c;
2943
+ // PATH lookup (which/where) as last resort.
2944
+ try {
2945
+ const { cp } = cdpBuiltins();
2946
+ const cmd = plat === "win32" ? "where chrome" : "which google-chrome chromium chromium-browser chrome";
2947
+ const out = cp.spawnSync(plat === "win32" ? "where" : "which", plat === "win32" ? ["chrome"] : ["google-chrome", "chromium", "chromium-browser", "chrome"], { encoding: "utf8", timeout: 5000 });
2948
+ const first = String((out && out.stdout) || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
2949
+ if (first && has(first)) return first;
2950
+ } catch { /* ignore */ }
2951
+ return null;
2952
+ }
2953
+ function findFfmpeg({ envPath } = {}) {
2954
+ const env = globalThis.process && globalThis.process.env ? globalThis.process.env : {};
2955
+ const configured = envPath || env.AURA_FFMPEG_PATH;
2956
+ if (configured) return configured;
2957
+ try {
2958
+ const { cp } = cdpBuiltins();
2959
+ const out = cp.spawnSync(globalThis.process && globalThis.process.platform === "win32" ? "where" : "which", ["ffmpeg"], { encoding: "utf8", timeout: 5000 });
2960
+ const first = String((out && out.stdout) || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
2961
+ return first || null;
2962
+ } catch { return null; }
2963
+ }
2964
+
2965
+ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl = null, spawnImpl = null, now = () => Date.now() } = {}) {
2966
+ const { fs, path, os, cp } = cdpBuiltins();
2967
+ const fetchFn = fetchImpl || globalThis.fetch;
2968
+ const WS = socketImpl || globalThis.WebSocket;
2969
+ const spawn = (spawnImpl && spawnImpl.spawn) || cp.spawn;
2970
+
2971
+ function executable() {
2972
+ const { fs: fss } = cdpBuiltins();
2973
+ if (chromePath) {
2974
+ try { if (fss.existsSync(chromePath)) return chromePath; } catch { /* fall through */ }
2975
+ throw cdpUnavailable("configured chrome not found: " + chromePath);
2976
+ }
2977
+ return findChromeExecutable({});
2978
+ }
2979
+ async function launchIsolated(port) {
2980
+ const exe = executable();
2981
+ if (!exe) throw cdpUnavailable("");
2982
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), "aura-cdp-"));
2983
+ const args = ["--headless=new", "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--disable-setuid-sandbox", "--user-data-dir=" + profile, "--remote-debugging-port=" + port, "--remote-allow-origins=*", "about:blank"];
2984
+ const child = (() => {
2985
+ try { return spawn(exe, args, { stdio: "ignore", windowsHide: true, detached: false }); }
2986
+ catch (e) { throw cdpUnavailable("launch failed: " + String((e && e.message) || e).slice(0, 160)); }
2987
+ })();
2988
+ // A bad executable surfaces as an async ENOENT 'error' event, not a
2989
+ // throw: record it so the port poll below fails fast with an honest code.
2990
+ let spawnErr = null;
2991
+ try { child.once("error", (e) => { spawnErr = e; }); } catch { /* ignore */ }
2992
+ const cleanup = () => { try { child.kill(); } catch { /* ignore */ } try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } };
2993
+ // Wait for /json/version (up to 15s).
2994
+ const deadline = Date.now() + 15000;
2995
+ let err = null;
2996
+ while (Date.now() < deadline) {
2997
+ if (spawnErr) { cleanup(); throw cdpUnavailable("launch failed: " + String((spawnErr && spawnErr.message) || spawnErr).slice(0, 160)); }
2998
+ try {
2999
+ const res = await fetchFn("http://127.0.0.1:" + port + "/json/version", { signal: AbortSignal.timeout(2000) });
3000
+ if (res && res.ok) {
3001
+ const j = await res.json();
3002
+ return { child, profile, cleanup, wsUrl: j.webSocketDebuggerUrl };
3003
+ }
3004
+ } catch (e) { err = e; }
3005
+ await new Promise((r) => setTimeout(r, 300));
3006
+ if (child.exitCode !== null && child.exitCode !== undefined) break;
3007
+ }
3008
+ cleanup();
3009
+ throw awardErr("BROWSER_UNAVAILABLE", "Chrome did not open a debug port (" + String((err && err.message) || "launch failed").slice(0, 160) + ")");
3010
+ }
3011
+ // Attach to a real page target via /json/list (stable across Chrome
3012
+ // builds). Target.createTarget over the browser endpoint hangs on several
3013
+ // headless=new builds, so it is only a last-resort fallback here.
3014
+ async function pageWsUrl(port) {
3015
+ const listUrl = "http://127.0.0.1:" + port + "/json/list";
3016
+ const read = async () => {
3017
+ const res = await fetchFn(listUrl, { signal: AbortSignal.timeout(3000) });
3018
+ if (!res || !res.ok) throw new Error("target list HTTP " + (res && res.status));
3019
+ return res.json();
3020
+ };
3021
+ let targets = await read();
3022
+ let page = (targets || []).find((t) => t && t.type === "page" && t.webSocketDebuggerUrl);
3023
+ if (!page) {
3024
+ try { await fetchFn("http://127.0.0.1:" + port + "/json/new?about:blank", { method: "PUT", signal: AbortSignal.timeout(5000) }); } catch { /* fall through to error below */ }
3025
+ targets = await read();
3026
+ page = (targets || []).find((t) => t && t.type === "page" && t.webSocketDebuggerUrl);
3027
+ }
3028
+ if (!page) throw new Error("no page target in " + listUrl);
3029
+ return page.webSocketDebuggerUrl;
3030
+ }
3031
+ // Bounded load wait: resolves on Page.loadEventFired, on network-idle
3032
+ // (no Page.lifecycleEvent for 2s after load when asked), or on timeout —
3033
+ // never hangs the tool call.
3034
+ function waitForSettled(conn, send, waitStrategy) {
3035
+ return new Promise((resolve) => {
3036
+ const cap = setTimeout(resolve, waitStrategy === "networkidle" ? 12000 : 15000);
3037
+ let lastActivity = Date.now();
3038
+ const idleTimer = waitStrategy === "networkidle" ? setInterval(() => {
3039
+ if (Date.now() - lastActivity > 2000) { clearInterval(idleTimer); clearTimeout(cap); resolve(); }
3040
+ }, 500) : null;
3041
+ const off = conn.onEvent("Page.loadEventFired", () => {
3042
+ lastActivity = Date.now();
3043
+ if (waitStrategy !== "networkidle") { if (idleTimer) clearInterval(idleTimer); clearTimeout(cap); off(); resolve(); }
3044
+ });
3045
+ conn.onEvent("Page.lifecycleEvent", () => { lastActivity = Date.now(); });
3046
+ });
3047
+ }
3048
+ async function withPage({ viewport, fn, timeoutMs = CDP_CAPTURE_TIMEOUT_MS, op = "capture" }) {
3049
+ const vp = cdpResolveViewport(viewport);
3050
+ const port = 19222 + Math.floor(Math.random() * 2000);
3051
+ const { child, cleanup } = await launchIsolated(port);
3052
+ const stopTimer = setTimeout(() => { try { child.kill(); } catch { /* ignore */ } }, timeoutMs + 15000);
3053
+ try {
3054
+ if (!WS) throw cdpUnavailable("WebSocket unavailable in this Node build.");
3055
+ let wsUrl;
3056
+ try { wsUrl = await pageWsUrl(port); }
3057
+ catch (e) { throw cdpSetupFailed(op, e); }
3058
+ const ws = new WS(wsUrl, []);
3059
+ try {
3060
+ await new Promise((resolve, reject) => {
3061
+ const t = setTimeout(() => reject(new Error("CDP socket connect timed out")), 10000);
3062
+ ws.onopen = () => { clearTimeout(t); resolve(); };
3063
+ ws.onerror = () => { clearTimeout(t); reject(new Error("CDP socket error")); };
3064
+ });
3065
+ } catch (e) { try { ws.close(); } catch { /* ignore */ } throw cdpSetupFailed(op, e); }
3066
+ const conn = createCdpConn({ send: (s) => ws.send(s), set onmessage(v) { ws.onmessage = (e) => v(e.data); } });
3067
+ const send = (method, params, t) => conn.command(method, params, t);
3068
+ try {
3069
+ await send("Page.enable", {}, 8000);
3070
+ await send("Emulation.setDeviceMetricsOverride", { width: vp.width, height: vp.height, deviceScaleFactor: vp.deviceScaleFactor || 1, mobile: !!vp.isMobile }, 8000);
3071
+ } catch (e) { try { ws.close(); } catch { /* ignore */ } throw cdpSetupFailed(op, e); }
3072
+ const out = await fn({ send, conn });
3073
+ try { ws.close(); } catch { /* ignore */ }
3074
+ return out;
3075
+ } finally { clearTimeout(stopTimer); cleanup(); }
3076
+ }
3077
+ return {
3078
+ status() {
3079
+ let exe = null;
3080
+ try { exe = executable(); } catch { exe = null; }
3081
+ return { available: !!exe, executable: exe, ffmpeg: findFfmpeg({}), viewports: Object.keys(CDP_VIEWPORTS) };
3082
+ },
3083
+ async capture(url, { viewport = "desktop", waitStrategy = "load", settleMs = 1500, allowLocal = false, saveDir = null } = {}) {
3084
+ const target = awardValidateHttpUrl(url, { allowLocal });
3085
+ if (!["load", "networkidle"].includes(waitStrategy)) throw cdpBad("waitStrategy must be load|networkidle");
3086
+ const settle = Math.max(0, Math.min(settleMs ?? 1500, CDP_MAX_SETTLE_MS));
3087
+ const exe = executable();
3088
+ if (!exe) throw cdpUnavailable("");
3089
+ const startedAt = new Date(now()).toISOString();
3090
+ const shot = await withPage({
3091
+ viewport, op: "capture",
3092
+ async fn({ send, conn }) {
3093
+ const settled = waitForSettled(conn, send, waitStrategy);
3094
+ await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3095
+ await settled;
3096
+ await new Promise((r) => setTimeout(r, settle));
3097
+ // Pre-scroll for lazy sections (bounded).
3098
+ try { await send("Runtime.evaluate", { expression: "(async()=>{for(let y=0;y<Math.min(document.documentElement.scrollHeight,10800);y+=450){scrollTo(0,y);await new Promise(r=>setTimeout(r,40));}scrollTo(0,0);await new Promise(r=>setTimeout(r,150));})()", awaitPromise: true }, 8000); } catch { /* best-effort */ }
3099
+ const meta = await send("Runtime.evaluate", { expression: "({title:document.title,w:innerWidth,h:innerHeight,total:Math.round(document.documentElement.scrollHeight)})", returnByValue: true }, 8000).catch(() => ({ result: { value: {} } }));
3100
+ const snap = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true }, 15000);
3101
+ return { meta: (meta && meta.result && meta.result.value) || {}, png: snap.data };
3102
+ },
3103
+ });
3104
+ const dir = saveDir || path.join(os.tmpdir(), "aura-captures");
3105
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
3106
+ const name = "capture-" + String(now()) + ".png";
3107
+ const file = path.join(dir, name);
3108
+ try { fs.writeFileSync(file, Buffer.from(shot.png, "base64")); } catch { /* return inline only */ }
3109
+ return { url: target, viewport, viewportPx: cdpResolveViewport(viewport), capturedAt: startedAt, artifactPath: file, bytes: Buffer.from(shot.png, "base64").length, page: shot.meta, limitations: ["lazy-loaded sections below the fold may still be placeholders", "blocked third-party resources are not retried"], source: "live-browser" };
3110
+ },
3111
+ async structure(url, { viewport = "desktop", allowLocal = false } = {}) {
3112
+ const target = awardValidateHttpUrl(url, { allowLocal });
3113
+ const exe = executable();
3114
+ if (!exe) throw cdpUnavailable("");
3115
+ const bands = await withPage({
3116
+ viewport, op: "structure",
3117
+ async fn({ send, conn }) {
3118
+ const settled = waitForSettled(conn, send, "load");
3119
+ await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3120
+ await settled;
3121
+ const res = await send("Runtime.evaluate", { expression: CDP_SCAN_JS, returnByValue: true }, 10000);
3122
+ return (res && res.result && res.result.value) || { title: target, totalHeight: 0, candidates: [] };
3123
+ },
3124
+ });
3125
+ const collapsed = cdpCollapseBands(bands.candidates || [], bands.totalHeight || 0);
3126
+ return { url: target, title: bands.title || target, totalHeight: bands.totalHeight || 0, bands: collapsed, viewport, viewportPx: cdpResolveViewport(viewport), source: "live-dom" };
3127
+ },
3128
+ async motionInspect(url, { viewport = "desktop", allowLocal = false } = {}) {
3129
+ const target = awardValidateHttpUrl(url, { allowLocal });
3130
+ const exe = executable();
3131
+ if (!exe) throw cdpUnavailable("");
3132
+ const scan = await withPage({
3133
+ viewport, op: "motion",
3134
+ async fn({ send, conn }) {
3135
+ const settled = waitForSettled(conn, send, "load");
3136
+ await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3137
+ await settled;
3138
+ await new Promise((r) => setTimeout(r, 1500));
3139
+ const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
3140
+ return (res && res.result && res.result.value) || { libs: [], render: [], scrollTrigger: { count: 0 }, tweens: { durations: [], eases: [] }, cssBeziers: [] };
3141
+ },
3142
+ });
3143
+ return reduceCdpScan(target, scan, now(), viewport);
3144
+ },
3145
+ // Bounded motion record: one session, N screenshots across a scroll tour.
3146
+ // Returns frame files + inspect evidence; webm only when ffmpeg exists.
3147
+ async recordFrames(url, { frames = 8, viewport = "desktop", allowLocal = false, saveDir = null, durationMs = 12000 } = {}) {
3148
+ const target = awardValidateHttpUrl(url, { allowLocal });
3149
+ const n = Math.max(2, Math.min(frames || 8, CDP_MAX_FRAMES));
3150
+ const budget = Math.max(4000, Math.min(durationMs || 12000, CDP_MAX_RECORD_MS));
3151
+ const exe = executable();
3152
+ if (!exe) throw cdpUnavailable("");
3153
+ const dir = saveDir || fs.mkdtempSync(path.join(os.tmpdir(), "aura-motion-"));
3154
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
3155
+ const startedAt = new Date(now()).toISOString();
3156
+ const stepMs = Math.floor(budget / (n + 2));
3157
+ const out = await withPage({
3158
+ viewport, timeoutMs: budget + 30000, op: "motion",
3159
+ async fn({ send, conn }) {
3160
+ const settled = waitForSettled(conn, send, "load");
3161
+ await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3162
+ await settled;
3163
+ await new Promise((r) => setTimeout(r, Math.min(4000, stepMs)));
3164
+ const files = [];
3165
+ for (let i = 0; i < n; i++) {
3166
+ try {
3167
+ await send("Runtime.evaluate", { expression: "scrollTo(0, Math.round(document.documentElement.scrollHeight * " + (i / Math.max(1, n - 1)) + "))", returnByValue: true }, 5000);
3168
+ } catch { /* ignore */ }
3169
+ await new Promise((r) => setTimeout(r, Math.min(stepMs, 3000)));
3170
+ try {
3171
+ const snap = await send("Page.captureScreenshot", { format: "jpeg", quality: 70 }, 10000);
3172
+ const file = path.join(dir, "frame-" + String(i).padStart(2, "0") + ".jpg");
3173
+ fs.writeFileSync(file, Buffer.from(snap.data, "base64"));
3174
+ files.push(file);
3175
+ } catch { /* partial capture: keep what we have */ }
3176
+ }
3177
+ let inspect = null;
3178
+ try {
3179
+ const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
3180
+ inspect = (res && res.result && res.result.value) || null;
3181
+ } catch { inspect = null; }
3182
+ return { files, inspect };
3183
+ },
3184
+ });
3185
+ const study = out.inspect ? reduceCdpScan(target, out.inspect, now(), viewport) : null;
3186
+ return { url: target, viewport, startedAt, frames: out.files, frameCount: out.files.length, study, dir };
3187
+ },
3188
+ async encodeWebm(frameFiles, { ffmpegPath = null } = {}) {
3189
+ const bin = ffmpegPath || findFfmpeg({});
3190
+ if (!bin) return { video: null, available: false, hint: "ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output" };
3191
+ if (!Array.isArray(frameFiles) || !frameFiles.length) throw cdpBad("no frames to encode");
3192
+ const dir = path.dirname(frameFiles[0]);
3193
+ const video = path.join(dir, "motion.webm");
3194
+ const args = ["-y", "-framerate", "2", "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
3195
+ const res = cp.spawnSync(bin, args, { timeout: 60000, windowsHide: true });
3196
+ if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size };
3197
+ return { video: null, available: false, hint: "ffmpeg encode failed (" + String(res && res.status) + "); frame JPEGs retained" };
3198
+ },
3199
+ };
3200
+ }
3201
+ function reduceCdpScan(url, scan, t, viewport) {
3202
+ const libs = Array.isArray(scan.libs) ? scan.libs : [];
3203
+ const render = Array.isArray(scan.render) ? scan.render : [];
3204
+ const st = scan.scrollTrigger || { count: 0, scrub: 0, pin: 0, sample: [] };
3205
+ const durations = ((scan.tweens && scan.tweens.durations) || []).filter((d) => typeof d === "number");
3206
+ const eases = ((scan.tweens && scan.tweens.eases) || []).concat(scan.cssBeziers || []).filter(Boolean);
3207
+ const agg = new Map();
3208
+ for (const raw of eases) {
3209
+ if (raw === "none" || raw === "linear") continue;
3210
+ const e = agg.get(raw) || { token: raw, uses: 0 };
3211
+ e.uses++;
3212
+ agg.set(raw, e);
3213
+ }
3214
+ const ms = durations.map((d) => d * 1000).sort((a, b) => a - b);
3215
+ const q = (p) => { if (!ms.length) return null; const i = p * (ms.length - 1); const lo = Math.floor(i), hi = Math.ceil(i); return Math.round(ms[lo] + (ms[hi] - ms[lo]) * (i - lo)); };
3216
+ return {
3217
+ url, viewport: viewport || "desktop",
3218
+ stack: { libs, render, scrollModel: libs.includes("lenis") ? "lenis" : libs.includes("locomotive") ? "locomotive" : libs.length ? "native" : "unknown" },
3219
+ scroll: { triggerCount: st.count || 0, scrubCount: st.scrub || 0, pinCount: st.pin || 0, scrubRatio: st.count ? (st.scrub || 0) / st.count : 0, sample: (st.sample || []).slice(0, 20) },
3220
+ easingVocab: [...agg.values()].sort((a, b) => b.uses - a.uses).slice(0, 15),
3221
+ durationVocab: ms.length ? { p25: q(0.25), median: q(0.5), p75: q(0.75) } : null,
3222
+ capturedAt: t, toolVersion: "aura/1.7.2", source: "live-browser",
3223
+ };
3224
+ }
3225
+
1633
3226
  // ---- tools.mjs ----
1634
3227
  // Module: tools — thin adapters. Each tool validates args at the seam,
1635
3228
  // calls one catalog/guide method, wraps textResult. No retries/caching here
@@ -1700,8 +3293,14 @@ const TOOL_DEFS = [
1700
3293
  { name: 'aura_docs_check', description: 'Check pasted code against fresh docs (pure). library + code_excerpt (under 20000 chars, excerpts not full pages) + optional docs_note -> verdict current|needs-update, look-preserving fixes, version-pin needs_check. Fresh API wins, look stays identical.', inputSchema: { type: 'object', properties: { library: { type: 'string' }, code_excerpt: { type: 'string' }, docs_note: { type: 'string' } }, required: ['library', 'code_excerpt'], additionalProperties: false } },
1701
3294
  { name: 'aura_icon', description: 'Fetch one free icon as ready-to-paste SVG (live). name in kebab-case (bolt, arrow-down). source auto|fontawesome|pixel, style auto|solid|regular|brands. Auto-fallback across styles then libraries; every hit carries license + credit (CC BY 4.0, credit always) plus a font/CSS alternative. 24h icon cache.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, source: { type: 'string', enum: ['auto', 'fontawesome', 'pixel'] }, style: { type: 'string', enum: ['solid', 'regular', 'brands', 'auto'] } }, required: ['name'], additionalProperties: false } },
1702
3295
  { name: 'aura_version', description: 'Running vs npm-published version (live, hourly cache). Returns update_available + notice telling the agent to prefer latest (npm i -g aura-components-mcp@latest) when behind. Never fails — offline registries return latest:null. aura_status already carries this verdict on every call.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
3296
+ { name: 'aura_award_search', description: 'Search award-winning website references (Awwwards). Modes: sites (ranked refs with preview links), categories (real taxonomy from the index/source), elements (hero/nav/pricing/footer examples), motion (previously recorded motion studies). Local index first, one polite live refresh when empty.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['sites', 'categories', 'elements', 'motion'] }, query: { type: 'string' }, slug: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, category: { type: 'string' }, color: { type: 'string' }, technology: { type: 'string' }, award: { type: 'string', enum: ['sotd', 'developer', 'honorable'] }, studio: { type: 'string' }, limit: { type: 'number' }, offset: { type: 'number' }, full: { type: 'boolean' }, library: { type: 'string' }, easing: { type: 'string' }, scrollModel: { type: 'string' } }, required: ['mode'], additionalProperties: false } },
3297
+ { name: 'aura_award_inspect', description: 'Inspect award refs: site_details (palette/tech/awards/evidence), site_elements (section visuals with media URLs), element (one gallery record), compare_sites (2-3 refs: shared vs gaps, missing marked unknown).', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['site_details', 'site_elements', 'element', 'compare_sites'] }, slug: { type: 'string' }, elementSlug: { type: 'string' }, slugs: { type: 'array', items: { type: 'string' } } }, required: ['mode'], additionalProperties: false } },
3298
+ { name: 'aura_design_blueprint', description: 'Evidence-backed build plan from 2-5 award refs + goal: Markdown brief + versioned JSON, mapped to real free Aura components/systems (free-only enforced, failures marked unavailable). Never copies third-party source.', inputSchema: { type: 'object', properties: { goal: { type: 'string' }, references: { type: 'array', items: { type: 'string' } }, technology: { type: 'string' }, visualDirection: { type: 'string' }, responsive: { type: 'string' }, constraints: { type: 'array', items: { type: 'string' } }, includeStructure: { type: 'boolean' } }, required: ['goal', 'references'], additionalProperties: false } },
3299
+ { name: 'aura_visual_audit', description: 'Capture/analyze a reference or your build. Modes: capture (real browser screenshot), structure (section band map: live-dom or static-html), compare (reference vs build mismatches). Needs Chrome for live capture; honest BROWSER_UNAVAILABLE otherwise. Loopback only with allowLocal:true.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['capture', 'structure', 'compare'] }, url: { type: 'string' }, referenceUrl: { type: 'string' }, buildUrl: { type: 'string' }, viewport: { type: 'string', enum: ['desktop', 'mobile'] }, waitStrategy: { type: 'string', enum: ['load', 'networkidle'] }, settleMs: { type: 'number' }, allowLocal: { type: 'boolean' } }, required: ['mode'], additionalProperties: false } },
3300
+ { name: 'aura_motion_study', description: 'Evidence-backed motion guidance. Modes: inspect (live runtime scan: libs/easing/scroll), record (bounded frame study; .webm only when ffmpeg present), search (stored studies by library/easing/scroll). Never claims unobserved library state.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['inspect', 'record', 'search'] }, url: { type: 'string' }, library: { type: 'string' }, easing: { type: 'string' }, scrollModel: { type: 'string' }, frames: { type: 'number' }, durationMs: { type: 'number' }, viewport: { type: 'string', enum: ['desktop', 'mobile'] }, allowLocal: { type: 'boolean' } }, required: ['mode'], additionalProperties: false } },
3301
+ { name: 'aura_award_index', description: 'Local award corpus: status (counts/freshness/lock), rebuild/resume/refresh (bounded polite crawl), new_winners (baseline then delta), watch_add/list/remove (studio/tag/url watches). Never auto-crawls on startup.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['status', 'rebuild', 'resume', 'refresh', 'new_winners', 'watch_add', 'watch_list', 'watch_remove'] }, award: { type: 'string', enum: ['sotd', 'developer', 'honorable'] }, maxPages: { type: 'number' }, kind: { type: 'string', enum: ['studio', 'tag', 'url'] }, pattern: { type: 'string' } }, required: ['action'], additionalProperties: false } },
1703
3302
  ];
1704
- function createHandlers(catalog, icons, version) {
3303
+ function createHandlers(catalog, icons, version, awardDeps) {
1705
3304
  // Automatic update notice: every aura_status carries the cached version
1706
3305
  // verdict (hourly npm re-check), so agents learn about updates unprompted.
1707
3306
  const versionNote = async () => {
@@ -1710,6 +3309,81 @@ function createHandlers(catalog, icons, version) {
1710
3309
  } catch { /* status must never fail on version check */ }
1711
3310
  return null;
1712
3311
  };
3312
+ // Award subsystem deps (optional for old callers): client (live fetch),
3313
+ // index (persistent JSON store), browser (native CDP). Missing pieces stay
3314
+ // honest: local-empty results + unavailable notes, never invented data.
3315
+ const awardClient = (awardDeps && awardDeps.awardClient) || null;
3316
+ const awardBrowser = (awardDeps && awardDeps.browser) || null;
3317
+ const awardIndex = (awardDeps && awardDeps.awardIndex) || awardFallbackIndex();
3318
+ function awardFallbackIndex() {
3319
+ const sites = new Map(); const elements = new Map(); const motions = [];
3320
+ let tax = { colors: [], filters: [] }; let watches = [];
3321
+ return {
3322
+ root: "(memory)", files: [],
3323
+ getStatus: () => ({ dir: "(memory)", version: 1, counts: { sites: sites.size, elements: elements.size, motion: motions.length, taxonomyFilters: tax.filters.length, taxonomyColors: tax.colors.length, watches: watches.length }, freshness: null, stale: true, progress: null, pendingPages: [], lock: { held: false } }),
3324
+ listSites: (n) => [...sites.values()].slice(0, n || 50),
3325
+ getSite: (slug) => { try { return sites.get(String(slug).toLowerCase()) || null; } catch { return null; } },
3326
+ upsertSites: (rows) => { let added = 0; for (const s of (rows || [])) { if (s && s.slug && !sites.has(String(s.slug).toLowerCase())) added++; if (s && s.slug) sites.set(String(s.slug).toLowerCase(), s); } return { added, updated: 0, total: sites.size }; },
3327
+ getTaxonomy: () => ({ version: 1, colors: tax.colors, filters: tax.filters }),
3328
+ setTaxonomy: (t) => { tax = { colors: (t.colors || []).slice(0, 100), filters: (t.filters || []).slice(0, 400) }; return { colors: tax.colors.length, filters: tax.filters.length }; },
3329
+ upsertElements: (rows) => { let added = 0; for (const e of (rows || [])) { if (e && e.slug && !elements.has(String(e.slug))) added++; if (e && e.slug) elements.set(String(e.slug), e); } return { added, total: elements.size }; },
3330
+ listElements: (n) => [...elements.values()].slice(0, n || 50),
3331
+ getElement: (slug) => elements.get(String(slug)) || null,
3332
+ recordMotion: (s) => { const i = motions.findIndex((m) => m.url === s.url); if (i >= 0) motions.splice(i, 1); motions.unshift(s); return s; },
3333
+ searchMotion: (f) => {
3334
+ let items = [...motions];
3335
+ if (f.library) items = items.filter((m) => JSON.stringify(m).toLowerCase().includes(String(f.library).toLowerCase()));
3336
+ if (f.easing) items = items.filter((m) => JSON.stringify(m.easingVocab || []).toLowerCase().includes(String(f.easing).toLowerCase()));
3337
+ if (f.scrollModel) items = items.filter((m) => String((m.stack && m.stack.scrollModel) || "").toLowerCase() === String(f.scrollModel).toLowerCase());
3338
+ return { total: items.length, items: items.slice(0, 10), empty: motions.length === 0 };
3339
+ },
3340
+ newWinners: async () => { throw awardErr("UNSUPPORTED_OPERATION", "new_winners needs a persistent index + award client; wire the server default"); },
3341
+ rebuild: async () => { throw awardErr("UNSUPPORTED_OPERATION", "rebuild needs a persistent index + award client"); },
3342
+ resume: async () => { throw awardErr("UNSUPPORTED_OPERATION", "resume needs a persistent index + award client"); },
3343
+ refresh: async () => { throw awardErr("UNSUPPORTED_OPERATION", "refresh needs a persistent index + award client"); },
3344
+ watchAdd: (w) => { if (watches.some((x) => x.kind === w.kind && x.pattern === w.pattern)) throw awardErr("BAD_ARGS", "duplicate watch"); if (watches.length >= 50) throw awardErr("BAD_ARGS", "too many watches"); const r = { ...w, createdAt: new Date().toISOString(), lastCheckedAt: null, lastSlugs: null }; watches.push(r); return r; },
3345
+ watchList: () => watches,
3346
+ watchRemove: ({ kind, pattern }) => { const b = watches.length; watches = watches.filter((w) => !(w.kind === kind && w.pattern === String(pattern).toLowerCase())); return { removed: watches.length < b }; },
3347
+ checkWatches: () => [],
3348
+ };
3349
+ }
3350
+ function awardMode(v, allowed, name) { if (v === undefined) toolFn_bad(name + ' is required'); if (!allowed.includes(v)) toolFn_bad(name + ' must be one of ' + allowed.join('|')); return v; }
3351
+ function awardLimit(v, def, max) { if (v === undefined) return def; if (typeof v !== 'number' || !Number.isFinite(v)) toolFn_bad('limit must be a number'); return Math.max(1, Math.min(Math.floor(v), max)); }
3352
+ function awardStrArr(v, name, max) { if (v === undefined) return undefined; if (!Array.isArray(v)) toolFn_bad(name + ' must be an array of strings'); if (v.length > max) toolFn_bad(name + ' holds max ' + max + ' entries'); for (const e of v) if (typeof e !== 'string' || !e.trim()) toolFn_bad(name + ' must hold non-empty strings'); return v.map((e) => e.trim()); }
3353
+ async function awardDetailFor(slug, { withStatic = false } = {}) {
3354
+ const norm = awardNormalizeSlug(slug);
3355
+ const cached = awardIndex.getSite(norm);
3356
+ if (!awardClient) return { slug: norm, summary: cached, detail: null, detailStatus: cached ? 'cached' : 'unavailable', liveError: 'award client not wired' };
3357
+ try {
3358
+ const { html } = await awardClient.getAwardHtml('/sites/' + norm);
3359
+ const detail = awardParseDetail(html, norm);
3360
+ const cachedTags = (cached && cached.tags) || [];
3361
+ // Tags vs Technologies consistency (#5): listing tags (WebGL, 3D, …)
3362
+ // exist even when the page has no Technologies section. Backfill from
3363
+ // tags and LABEL it inferred — never present it as parsed section data.
3364
+ if ((!detail.technologies || !detail.technologies.length) && cachedTags.length) {
3365
+ detail.technologies = [...new Set(cachedTags.map((t) => String(t)))].slice(0, 12);
3366
+ detail.technologiesInferred = true;
3367
+ detail.technologyEvidence = "inferred from listing tags; page has no Technologies section";
3368
+ }
3369
+ const summary = { slug: norm, title: detail.title || (cached && cached.title) || norm, createdAt: (cached && cached.createdAt) || 0, tags: (cached && cached.tags) || [], thumbnailPath: (cached && cached.thumbnailPath) || "", liveUrl: detail.liveUrl || (cached && cached.liveUrl) || null, detailPath: "/sites/" + norm, awards: (detail.awards || []).map((a) => a.title), studio: (cached && cached.studio) || null, source: "awwwards", sourceUrl: detail.sourceUrl };
3370
+ try { awardIndex.upsertSites([summary]); } catch { /* memory fallback never fails */ }
3371
+ try {
3372
+ const media = awardParseElementsSection(html);
3373
+ if (media && media.length) awardIndex.upsertElements(media.map((m) => ({ slug: 'site-' + norm + '-' + String(m.title || 'element').toLowerCase().replace(/\W+/g, '-').slice(0, 60), title: m.title || 'Element', author: summary.studio || summary.title, builtWith: [], related: [], mediaPath: m.mediaPath, mediaType: String(m.mediaPath).endsWith('.mp4') ? 'video' : 'image', mediaUrl: awardElementUrl(m.mediaPath), category: 'uncategorized', siteSlug: norm, source: 'site', sourceUrl: summary.sourceUrl })));
3374
+ } catch { /* backfill best-effort */ }
3375
+ // Static motion cues (opt-in per caller: one extra bounded fetch).
3376
+ // Recorded studies stay authoritative; static markup is supplementary.
3377
+ let motionStatic = null;
3378
+ if (withStatic && summary.liveUrl) {
3379
+ try { motionStatic = await awardStaticMotion(summary.liveUrl); } catch { motionStatic = { status: "unavailable", url: summary.liveUrl, reason: "static scan failed" }; }
3380
+ }
3381
+ return { slug: norm, summary, detail, detailStatus: 'fetched', liveError: null, ...(motionStatic ? { motion_static: motionStatic } : {}) };
3382
+ } catch (e) {
3383
+ if (e && e.code === 'NOT_FOUND') throw e;
3384
+ return { slug: norm, summary: cached, detail: null, detailStatus: cached ? 'cached' : 'unavailable', liveError: String((e && e.message) || e).slice(0, 220) };
3385
+ }
3386
+ }
1713
3387
  return {
1714
3388
  aura_status: async () => {
1715
3389
  const [status, update] = await Promise.all([catalog.getStatus(), versionNote()]);
@@ -1880,7 +3554,409 @@ function createHandlers(catalog, icons, version) {
1880
3554
  const sysItem = sys && sys.item ? sys.item : null;
1881
3555
  const toks = sysItem ? tokenHints(sysItem.content || '') : { tokens: {}, css: '' };
1882
3556
  const { guides, combined_deps: deps, files } = combineScaffold(sysItem, freeComps);
1883
- return textResult({ goal: a.goal, order: ['1 tokens.css', '2 system preview_html', '3 component markup in listed order'], tokens_css: toks.css || '', system: sysItem, components: freeComps || [], guides, combined_deps: deps, files, docs_stamp: docsStampFor(deps), ...(bundle.dropped ? { dropped_refs: bundle.dropped, dropped_note: 'Some requested refs resolved to missing or Pro (paid) components and were omitted — see per-id errors via aura_bundle.' } : {}), ...(proFiltered ? { pro_filtered_out: proFiltered, pro_note: 'Pro (paid) components were refused — free-only scaffold ships free rows.' } : {}), license_note: 'Markup and any pulled assets are all-rights-reserved per Aura Terms §4 — fine to prototype, check each page_url before commercial ship.' }); },
3557
+ return textResult({ goal: a.goal, order: ['1 tokens.css', '2 system preview_html', '3 component markup in listed order'], tokens_css: toks.css || '', system: sysItem, ...(sysItem ? {} : { system_note: 'no design system matched this goal — tokens.css is empty; fetch one via aura_search_design_systems or proceed with component defaults' }), components: freeComps || [], guides, combined_deps: deps, files, docs_stamp: docsStampFor(deps), ...(bundle.dropped ? { dropped_refs: bundle.dropped, dropped_note: 'Some requested refs resolved to missing or Pro (paid) components and were omitted — see per-id errors via aura_bundle.' } : {}), ...(proFiltered ? { pro_filtered_out: proFiltered, pro_note: 'Pro (paid) components were refused — free-only scaffold ships free rows.' } : {}), license_note: 'Markup and any pulled assets are all-rights-reserved per Aura Terms §4 — fine to prototype, check each page_url before commercial ship.' }); },
3558
+ aura_award_search: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['sites', 'categories', 'elements', 'motion'], 'mode');
3559
+ const limit = awardLimit(a.limit, 5, 25); const offset = Math.max(0, a.offset ?? 0); const full = a.full === undefined ? false : toolFn_bool(a.full, 'full');
3560
+ if (mode === 'categories') {
3561
+ let tax = awardIndex.getTaxonomy();
3562
+ let source = 'local-index';
3563
+ if ((!tax.filters.length && !tax.colors.length) && awardClient) {
3564
+ try { const { html } = await awardClient.getAwardHtml('/websites/'); const parsed = awardParseCategories(html); awardIndex.setTaxonomy(parsed); tax = awardIndex.getTaxonomy(); source = 'live'; }
3565
+ catch (e) { return textResult({ mode, colors: [], filters: [], source: 'unavailable', liveError: String((e && e.message) || e).slice(0, 220), note: 'taxonomy unavailable; rebuild the index or retry later' }); }
3566
+ }
3567
+ // Paginated: colors are few (return all); filters page with limit/offset.
3568
+ const allFilters = tax.filters || [];
3569
+ return textResult({ mode, colors: tax.colors || [], filters: allFilters.slice(offset, offset + limit), filtersTotal: allFilters.length, limit, offset, source, updatedAt: tax.updatedAt || null, note: allFilters.length > limit ? 'filters paged — raise offset for more' : undefined });
3570
+ }
3571
+ if (mode === 'motion') {
3572
+ const r = awardIndex.searchMotion({ library: toolFn_str(a.library, 'library'), easing: toolFn_str(a.easing, 'easing'), scrollModel: a.scrollModel === undefined ? undefined : String(a.scrollModel), limit });
3573
+ if (r.empty) return textResult({ mode, total: 0, items: [], emptyCorpus: true, note: 'no motion studies recorded yet — run aura_motion_study {mode:inspect|record} first' });
3574
+ return textResult({ mode, total: r.total, items: r.items, emptyCorpus: false });
3575
+ }
3576
+ if (mode === 'elements') {
3577
+ const q = toolFn_str(a.query, 'query') || ''; const cat = toolFn_str(a.category, 'category');
3578
+ let pool = awardIndex.listElements(500);
3579
+ if (!pool.length && awardClient && q) {
3580
+ // Honest empty: element gallery needs per-item fetches; point at winners backfill instead of scraping blindly.
3581
+ return textResult({ mode, total: 0, items: [], note: 'element corpus is empty; fetch site elements via aura_award_inspect {mode:site_elements} or record new_winners to backfill' });
3582
+ }
3583
+ let items = pool;
3584
+ if (cat) items = items.filter((e) => String(e.category || '').toLowerCase() === cat.toLowerCase() || String(e.title || '').toLowerCase().includes(cat.toLowerCase()));
3585
+ if (q) {
3586
+ const toks = awardTokens(q);
3587
+ items = items.map((e) => { const hay = (String(e.title || '') + ' ' + String(e.author || '') + ' ' + String(e.category || '')).toLowerCase(); let s = 0; for (const t of toks) if (hay.includes(t)) s++; return { e, s }; }).filter((x) => x.s > 0).sort((x, y) => y.s - x.s || String(x.e.title).localeCompare(String(y.e.title))).map((x) => x.e);
3588
+ } else items = [...items].sort((x, y) => String(x.title).localeCompare(String(y.title)));
3589
+ const page = items.slice(offset, offset + limit).map((e) => ({ slug: e.slug, title: e.title, author: e.author || null, category: e.category || null, mediaUrl: e.mediaUrl || (e.mediaPath ? awardElementUrl(e.mediaPath) : null), mediaType: e.mediaType || null, siteSlug: e.siteSlug || null, sourceUrl: e.sourceUrl || null, evidence: { source: 'awwwards' } }));
3590
+ return textResult({ mode, query: q || null, total: items.length, limit, offset, items: page, ...(page.length ? {} : { suggestions: awardSuggestQueries(q || 'hero', awardIndex.getTaxonomy()) }) });
3591
+ }
3592
+ // sites
3593
+ const query = toolFn_str(a.query, 'query') || ''; const slugQ = a.slug === undefined ? undefined : awardNormalizeSlug(a.slug);
3594
+ const tech = toolFn_str(a.technology, 'technology'); const color = toolFn_str(a.color, 'color'); const awardF = a.award; const studio = toolFn_str(a.studio, 'studio');
3595
+ if (awardF !== undefined && !['sotd', 'developer', 'honorable'].includes(awardF)) toolFn_bad('award must be sotd|developer|honorable');
3596
+ if (color !== undefined && !/^#?[0-9a-fA-F]{6}$/.test(color.trim())) toolFn_bad('color must be a hex like #FF5733');
3597
+ let pool = awardIndex.listSites(2000);
3598
+ let liveError = null, liveRefreshed = false;
3599
+ if (!pool.length && awardClient) {
3600
+ try {
3601
+ const { html } = await awardClient.getAwardHtml(awardFilterUrl({ color: color ? '#' + String(color).replace('#', '') : undefined, tags: a.tags, technology: tech, award: awardF }));
3602
+ const parsed = awardParseListing(html);
3603
+ if (parsed.length) { awardIndex.upsertSites(parsed); pool = awardIndex.listSites(2000); liveRefreshed = true; }
3604
+ } catch (e) { liveError = String((e && e.message) || e).slice(0, 220); }
3605
+ }
3606
+ let filtered = pool;
3607
+ if (slugQ) filtered = filtered.filter((s) => s.slug === slugQ);
3608
+ if (tech) filtered = filtered.filter((s) => (s.tags || []).map((t) => String(t).toLowerCase()).includes(tech.toLowerCase()) || JSON.stringify(s).toLowerCase().includes(tech.toLowerCase()));
3609
+ if (studio) filtered = filtered.filter((s) => String(s.studio || '').toLowerCase().includes(studio.toLowerCase()));
3610
+ if (Array.isArray(a.tags) && a.tags.length) {
3611
+ const want = a.tags.map((t) => String(t).toLowerCase());
3612
+ filtered = filtered.filter((s) => want.some((w) => (s.tags || []).map((t) => String(t).toLowerCase()).includes(w)));
3613
+ }
3614
+ const { tokens, scored } = awardScoreSites(filtered, query || slugQ || '', { technology: tech, color });
3615
+ const ranked = query ? scored.filter((x) => x.score > 0).map((x) => ({ s: x.site, score: x.score, reasons: x.reasons })) : scored.map((x) => ({ s: x.site, score: x.score, reasons: x.reasons }));
3616
+ const total = ranked.length;
3617
+ const page = ranked.slice(offset, offset + limit);
3618
+ // Full-detail enrichment runs concurrently (the award client spaces
3619
+ // requests 1/s) but is capped: enriching 25 rows would take ~25s.
3620
+ const FULL_CAP = 5;
3621
+ const enrich = full ? page.slice(0, FULL_CAP) : [];
3622
+ const enriched = await Promise.all(enrich.map(async (r) => {
3623
+ try { const d = await awardDetailFor(r.s.slug); return awardFullSite(d.summary || r.s, d.detail, { score: r.score, reasons: r.reasons }); }
3624
+ catch { return awardCompactSite(r.s, { score: r.score, reasons: r.reasons }); }
3625
+ }));
3626
+ const items = page.map((r, i) => (full && i < enriched.length ? enriched[i] : awardCompactSite(r.s, { score: r.score, reasons: r.reasons })));
3627
+ const out = { mode, query: query || null, tokens, total, limit, offset, items, source: liveRefreshed ? 'live' : 'local-index', ...(liveError ? { liveError } : {}),
3628
+ ...(full && page.length > FULL_CAP ? { full_enrichment_capped: FULL_CAP, full_note: 'only the first ' + FULL_CAP + ' hits carry full detail (polite-fetch bound); the rest are compact' } : {}),
3629
+ corpusNote: liveRefreshed ? 'local index was empty; seeded from one polite live listing fetch (rebuild the index for depth)' : 'ranked from the local index; rebuild/refresh the index when stale' };
3630
+ if (!items.length && !liveError) { out.suggestions = awardSuggestQueries(query || 'portfolio', awardIndex.getTaxonomy()); out.note = 'zero-match (not a failure): try a suggestion'; }
3631
+ if (!items.length && liveError) out.note = 'upstream fetch failed; local index also empty — retry later, do not treat as zero-match';
3632
+ return textResult(out); },
3633
+ aura_award_inspect: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['site_details', 'site_elements', 'element', 'compare_sites'], 'mode');
3634
+ if (mode === 'element') {
3635
+ const slug = awardNormalizeSlug(a.elementSlug || toolFn_bad('elementSlug (string) is required'));
3636
+ let rec = awardIndex.getElement(slug);
3637
+ if (!rec && awardClient) {
3638
+ try { const { html } = await awardClient.getAwardHtml('/inspiration/' + slug); const parsed = awardParseElementPage(html); if (parsed) { awardIndex.upsertElements([{ ...parsed }]); rec = awardIndex.getElement(slug) || parsed; } } catch (e) { if (e && e.code === 'NOT_FOUND') throw e; }
3639
+ }
3640
+ if (!rec) { const e = new Error('element not found: ' + slug); e.code = 'NOT_FOUND'; throw e; }
3641
+ return textResult({ mode, element: rec });
3642
+ }
3643
+ if (mode === 'compare_sites') {
3644
+ const slugs = awardStrArr(a.slugs, 'slugs', 3) || toolFn_bad('slugs (array of 2-3) is required');
3645
+ if (slugs.length < 2) toolFn_bad('compare_sites needs 2-3 slugs');
3646
+ const normed = slugs.map((s) => awardNormalizeSlug(s));
3647
+ // Concurrent detail fetch (polite 1/s via the client); NOT_FOUND on
3648
+ // any ref still throws, other failures stay per-ref unavailable.
3649
+ const settled = await Promise.all(normed.map(async (s) => {
3650
+ try { return { ok: true, d: await awardDetailFor(s) }; }
3651
+ catch (e) { return { ok: false, slug: s, err: e }; }
3652
+ }));
3653
+ for (const r of settled) if (!r.ok && r.err && r.err.code === 'NOT_FOUND') throw r.err;
3654
+ const entries = settled.map((r) => {
3655
+ const s = r.ok ? r.d.slug : r.slug;
3656
+ let motion = null;
3657
+ try { const mr = awardIndex.searchMotion({ limit: 25 }); motion = (mr.items || []).find((m) => String(m.url || '').toLowerCase().includes(s)) || null; } catch { motion = null; }
3658
+ return { slug: s, detail: r.ok ? r.d.detail : null, motion };
3659
+ });
3660
+ const cmp = awardCompareSites(entries);
3661
+ return textResult({ mode, compared: cmp.per, shared: { technology: cmp.sharedTech, technologyFamilies: cmp.sharedTechFamilies, elements: cmp.sharedElements, elementKeywords: cmp.sharedElementKeywords, palette: cmp.sharedPalette, luminanceBands: cmp.sharedBands }, directionHints: cmp.directionHints, gaps: cmp.per.flatMap((p) => p.gaps.map((g) => p.slug + ': ' + g)), note: 'missing fields are unknown evidence, not negative proof' });
3662
+ }
3663
+ const slug = awardNormalizeSlug(a.slug || toolFn_bad('slug (string) is required'));
3664
+ const d = await awardDetailFor(slug, { withStatic: true });
3665
+ if (!d.summary && !d.detail) { const e = new Error('site not found: ' + slug); e.code = 'NOT_FOUND'; throw e; }
3666
+ if (mode === 'site_details') {
3667
+ let motion = null;
3668
+ try { const mr = awardIndex.searchMotion({ limit: 25 }); motion = (mr.items || []).find((m) => String(m.url || '').toLowerCase().includes(slug)) || null; } catch { motion = null; }
3669
+ return textResult({ mode, slug, summary: d.summary ? awardCompactSite(d.summary, null) : null, detail: d.detail, ideas: awardSiteIdeas(d.detail), ideas_contract: '0-6 ideas by held evidence (1 on thin evidence is valid); every idea is status:suggested with an observed basis, never stated fact', motion: motion || null, motion_static: d.motion_static || null, evidence: { detail: d.detail ? d.detailStatus : 'unavailable', motion: motion ? 'cached' : 'unavailable', motion_static: d.motion_static ? d.motion_static.status : 'unavailable', ...(d.liveError ? { liveError: d.liveError } : {}) } });
3670
+ }
3671
+ // site_elements
3672
+ let elements = [];
3673
+ if (d.detail && d.detail.elements) {
3674
+ const media = awardParseElementsSection((await (async () => { try { const r = await awardClient.getAwardHtml('/sites/' + slug); return r.html; } catch { return ''; } })())) || [];
3675
+ elements = (media.length ? media : d.detail.elements.map((t) => ({ title: t, mediaPath: null }))).map((m) => ({ title: m.title, mediaUrl: m.mediaPath ? awardElementUrl(m.mediaPath) : null, mediaType: m.mediaPath ? (String(m.mediaPath).endsWith('.mp4') ? 'video' : 'image') : null, siteSlug: slug, sourceUrl: awardSiteUrl(slug), evidence: { source: 'awwwards' } }));
3676
+ } else {
3677
+ const pool = awardIndex.listElements(500).filter((e) => e.siteSlug === slug);
3678
+ elements = pool.map((e) => ({ title: e.title, mediaUrl: e.mediaUrl || (e.mediaPath ? awardElementUrl(e.mediaPath) : null), mediaType: e.mediaType, siteSlug: slug, sourceUrl: e.sourceUrl || awardSiteUrl(slug), evidence: { source: 'awwwards' } }));
3679
+ }
3680
+ return textResult({ mode, slug, sourceUrl: awardSiteUrl(slug), elements, ...(elements.length ? {} : { note: 'no Elements section on this page (legitimate empty) or detail unavailable' }) }); },
3681
+ aura_design_blueprint: async (a) => { a = a || {}; const goal = typeof a.goal === 'string' ? a.goal.trim() : toolFn_bad('goal (string) is required');
3682
+ if (!goal) toolFn_bad('goal (string) is required');
3683
+ const refs = awardStrArr(a.references, 'references', 5) || toolFn_bad('references (array of 2-5 slugs) is required');
3684
+ if (refs.length < 2 || refs.length > 5) toolFn_bad('references must hold 2-5 slugs — got ' + refs.length);
3685
+ const normed = refs.map((s) => awardNormalizeSlug(s));
3686
+ const technology = toolFn_str(a.technology, 'technology'); const visualDirection = toolFn_str(a.visualDirection, 'visualDirection'); const responsive = toolFn_str(a.responsive, 'responsive');
3687
+ const constraints = Array.isArray(a.constraints) ? a.constraints.filter((c) => typeof c === 'string').map((c) => c.slice(0, 200)).slice(0, 8) : [];
3688
+ const includeStructure = a.includeStructure === undefined ? false : toolFn_bool(a.includeStructure, 'includeStructure');
3689
+ const details = (await Promise.all(normed.map(async (s) => {
3690
+ try { return await awardDetailFor(s, { withStatic: true }); }
3691
+ catch (e) {
3692
+ if (e && e.code === 'NOT_FOUND') throw e;
3693
+ return { slug: s, summary: awardIndex.getSite(s), detail: null, detailStatus: 'unavailable', liveError: String((e && e.message) || e).slice(0, 160) };
3694
+ }
3695
+ })));
3696
+ // Aura catalogue matches (real rows only; failures stay unavailable).
3697
+ // One query PER proposed section so every section gets a candidate —
3698
+ // theme words from the goal ride along (dark/light re-rank server-side).
3699
+ const auraMatches = []; let auraUnavailable = null; let sysSlugForScaffold = null;
3700
+ const SECTIONS = AWARD_SECTIONS;
3701
+ try {
3702
+ if (!catalog || typeof catalog.searchCatalog !== 'function') throw new Error('catalogue not wired');
3703
+ const themeWord = /\b(dark|light)\b/i.test(goal + ' ' + (visualDirection || '')) ? goal.match(/\b(dark|light)\b/i)[1].toLowerCase() : undefined;
3704
+ const seen = new Set();
3705
+ const queries = [];
3706
+ for (const sec of SECTIONS) {
3707
+ const refWords = [];
3708
+ for (const d of details) {
3709
+ const toks = awardTokens(((d.detail && d.detail.title) || '') + ' ' + ((d.detail && (d.detail.elements || []).join(' ')) || '')).slice(0, 2);
3710
+ if (toks.length) refWords.push(toks[0]);
3711
+ }
3712
+ const q = [...new Set([sec, ...refWords.slice(0, 1), themeWord].filter(Boolean))].join(' ');
3713
+ queries.push({ section: sec, q });
3714
+ }
3715
+ // Section queries run concurrently (catalogue coalesces + caches);
3716
+ // results re-assemble in section order with duplicate ids collapsed.
3717
+ const sectionResults = await Promise.all(queries.map(async ({ section, q }) => {
3718
+ try {
3719
+ const r = await catalog.searchCatalog('components', { query: q, freeOnly: true, limit: 2, ...(themeWord ? { theme: themeWord } : {}) });
3720
+ return { section, q, items: (r.items || []).slice(0, 1), fallback: r && r.isFallback ? (r.fallback || 'fallback') : null };
3721
+ } catch { return { section, q, items: [], fallback: null }; }
3722
+ }));
3723
+ const fbBySection = {};
3724
+ for (const { section, fallback } of sectionResults) if (fallback && !(section in fbBySection)) fbBySection[section] = fallback;
3725
+ for (const { section, q, items } of sectionResults) {
3726
+ if (auraMatches.length >= 12) break;
3727
+ for (const it of items) {
3728
+ if (!it || it.premium !== false) continue;
3729
+ const key = String(it.id || it.slug);
3730
+ if (seen.has(key)) continue;
3731
+ seen.add(key);
3732
+ auraMatches.push({ section, componentId: it.id ?? it.slug, componentTitle: it.title, pageUrl: it.page_url, why: 'catalogue match for "' + q + '"', matchEvidence: { fallback: fbBySection[section] || null } });
3733
+ break;
3734
+ }
3735
+ }
3736
+ try {
3737
+ const sys = await catalog.searchCatalog('design_systems', { query: goal, limit: 1 });
3738
+ if (sys.items && sys.items[0]) { auraMatches.unshift({ section: 'tokens', componentId: sys.items[0].slug || sys.items[0].id, componentTitle: sys.items[0].title, pageUrl: sys.items[0].page_url, why: 'design-system tokens first' }); sysSlugForScaffold = sys.items[0].slug || sys.items[0].id; }
3739
+ } catch { /* optional */ }
3740
+ const covered = new Set(auraMatches.map((m) => m.section));
3741
+ const uncovered = SECTIONS.filter((s) => !covered.has(s));
3742
+ if (uncovered.length) auraUnavailable = (auraUnavailable ? auraUnavailable + '; ' : '') + 'no free catalogue match for sections: ' + uncovered.join(', ') + ' — build these from tokens + closest matched section';
3743
+ // Weak-consensus fallback: fewer than 3 component matches is not an
3744
+ // actionable brief. Fill from human-grade editorial picks, LABELED as
3745
+ // such (editorial:true) — never silently presented as query matches.
3746
+ const compCount = auraMatches.filter((m) => m.section !== 'tokens').length;
3747
+ if (compCount < 3 && catalog && typeof catalog.editorialPicks === 'function') {
3748
+ try {
3749
+ const picks = await catalog.editorialPicks(3);
3750
+ for (const p of (picks || [])) {
3751
+ if (auraMatches.length >= 12) break;
3752
+ if (!p || p.premium !== false) continue;
3753
+ const key = String(p.id || p.slug);
3754
+ if (seen.has(key)) continue;
3755
+ seen.add(key);
3756
+ auraMatches.push({ section: 'general', componentId: p.id ?? p.slug, componentTitle: p.title, pageUrl: p.page_url, why: 'editorial fallback: goal matched nothing directly — human-grade free pick', editorial: true });
3757
+ }
3758
+ if ((picks || []).length) auraUnavailable = (auraUnavailable ? auraUnavailable + '; ' : '') + 'section queries matched thinly — editorial free picks fill the kit (marked editorial:true)';
3759
+ } catch { /* brief stays thin but honest */ }
3760
+ }
3761
+ } catch (e) { auraUnavailable = String((e && e.message) || e).slice(0, 200); }
3762
+ let structureNotes = null;
3763
+ if (includeStructure) {
3764
+ if (!awardBrowser) structureNotes = 'structure not captured: browser unavailable (install Chrome or set AURA_CHROME_PATH)';
3765
+ else {
3766
+ const parts = await Promise.all(normed.slice(0, 3).map(async (s) => {
3767
+ const d = details.find((x) => x.slug === s);
3768
+ const live = d && d.detail && d.detail.liveUrl;
3769
+ if (!live) return s + ': no live URL in evidence';
3770
+ try { const st = await awardBrowser.structure(live, { viewport: 'desktop' }); return s + ': ' + st.bands.length + ' bands over ' + st.totalHeight + 'px (live-dom)'; }
3771
+ catch (e) { return s + ': structure unavailable (' + String((e && e.message) || e).slice(0, 120) + ')'; }
3772
+ }));
3773
+ structureNotes = parts.join('; ');
3774
+ }
3775
+ }
3776
+ let motionNotes = null;
3777
+ const gaps = [];
3778
+ for (const d of details) {
3779
+ if (!d.detail) gaps.push(d.slug + ': reference detail unavailable' + (d.liveError ? ' (' + d.liveError + ')' : ''));
3780
+ }
3781
+ const staticCues = [];
3782
+ for (const d of details) {
3783
+ const line = awardStaticSummary(d.slug, d.motion_static);
3784
+ if (line) staticCues.push(line);
3785
+ }
3786
+ try {
3787
+ const mr = awardIndex.searchMotion({ limit: 25 });
3788
+ const hits = (mr.items || []).filter((m) => normed.some((s) => String(m.url || '').toLowerCase().includes(s)));
3789
+ if (hits.length) motionNotes = 'Recorded motion for ' + hits.map((h) => h.url).join(', ') + ': prefer ' + hits.map((h) => (h.stack && h.stack.libs || []).join('+') || 'css').join('; ');
3790
+ else {
3791
+ if (staticCues.length) motionNotes = 'Static markup cues (NOT runtime observation — record a study for trigger counts/easing): ' + staticCues.join(' | ');
3792
+ gaps.push('motion: no recorded studies for these refs — run aura_motion_study {mode:inspect} on their live URLs, or follow the hand-written <500ms + prefers-reduced-motion guidance');
3793
+ }
3794
+ } catch { /* ignore */ }
3795
+ if (!includeStructure) gaps.push('structure: not requested (pass includeStructure:true for measured band evidence)');
3796
+ if (auraUnavailable) gaps.push('catalogue: ' + auraUnavailable);
3797
+ // Requested palette synthesis: hexes named in the goal/direction become
3798
+ // PROPOSED tokens (never presented as observed ref evidence), mapped
3799
+ // against the refs' observed luminance bands.
3800
+ const requestedPalette = [...new Set([...String(goal + ' ' + (visualDirection || '')).matchAll(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g)].map((m) => { const h = m[1]; return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h).toUpperCase(); }))].slice(0, 6);
3801
+ const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
3802
+ // next_calls: immediately executable follow-ups built ONLY from real
3803
+ // ids/urls gathered above — the agent acts without another search round.
3804
+ const compMatches = auraMatches.filter((m) => m.section !== 'tokens' && !m.editorial).slice(0, 3);
3805
+ const nextCalls = compMatches.map((m) => ({ tool: 'aura_get_component', arguments: { id: m.componentId }, why: 'read full markup for the ' + m.section + ' pick before pasting' }));
3806
+ const scaffoldComps = auraMatches.filter((m) => m.section !== 'tokens').slice(0, 8).map((m) => String(m.componentId));
3807
+ if (scaffoldComps.length) nextCalls.push({ tool: 'aura_scaffold_page', arguments: { goal, ...(sysSlugForScaffold ? { system: sysSlugForScaffold } : {}), components: scaffoldComps }, why: 'one-turn page build from the matched kit (tokens first, then markup)' });
3808
+ for (const d of details) {
3809
+ if (d.detail && d.detail.liveUrl) nextCalls.push({ tool: 'aura_motion_study', arguments: { mode: 'inspect', url: d.detail.liveUrl }, why: 'record real motion evidence for ' + d.slug + ' before animating' });
3810
+ if (nextCalls.length >= 6) break;
3811
+ }
3812
+ return textResult({ goal, references: normed, markdown: built.markdown, spec: built.spec, auraEvidence: auraMatches.length ? { matched: auraMatches.length, sectionsCovered: [...new Set(auraMatches.map((m) => m.section))] } : { matched: 0, unavailable: auraUnavailable || 'catalogue lookup failed or matched nothing' }, next_calls: nextCalls, license_note: 'Reference design belongs to its creators (see sourceUrl per ref); Aura matches are all-rights-reserved per Aura Terms §4 — adapt patterns, never copy source.' }); },
3813
+ aura_visual_audit: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['capture', 'structure', 'compare'], 'mode');
3814
+ const viewport = a.viewport === undefined ? 'desktop' : awardMode(a.viewport, ['desktop', 'mobile'], 'viewport');
3815
+ const waitStrategy = a.waitStrategy === undefined ? 'load' : awardMode(a.waitStrategy, ['load', 'networkidle'], 'waitStrategy');
3816
+ const settleMs = a.settleMs === undefined ? 1500 : toolFn_num(a.settleMs, 'settleMs');
3817
+ if (typeof settleMs !== 'number' || settleMs < 0 || settleMs > 8000) toolFn_bad('settleMs must be 0-8000');
3818
+ const allowLocal = a.allowLocal === undefined ? false : toolFn_bool(a.allowLocal, 'allowLocal');
3819
+ // Local-file audit (P0-2): a file:// build path or plain local path is
3820
+ // readable WITHOUT a browser when allowLocal:true — parsed as labeled
3821
+ // static-html estimates (source:local-file). Screenshots still need http.
3822
+ const readLocalSource = async (raw, label) => {
3823
+ if (!allowLocal) { const e = new Error(label + ' is a local path: pass allowLocal:true to audit local builds (structure mode reads the file directly; capture needs a served http URL)'); e.code = 'UNSUPPORTED_OPERATION'; throw e; }
3824
+ let fs, path;
3825
+ try {
3826
+ const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
3827
+ if (typeof g !== "function") throw new Error("needs Node 22+");
3828
+ fs = g.call(globalThis.process, "node:fs"); path = g.call(globalThis.process, "node:path");
3829
+ } catch { const e = new Error('local file audit needs Node 22+'); e.code = 'UNSUPPORTED_OPERATION'; throw e; }
3830
+ let file = String(raw);
3831
+ if (/^file:\/\//i.test(file)) { try { file = new URL(file).pathname; } catch { toolFn_bad(label + ' is not a readable file URL'); } if (/^\/[A-Za-z]:\//.test(file)) file = file.slice(1); }
3832
+ if (/[\0]/.test(file) || file.length > 1000) toolFn_bad(label + ' is not a readable local file');
3833
+ let html;
3834
+ try { html = fs.readFileSync(file, "utf8"); }
3835
+ catch { const e = new Error(label + ' not found or unreadable: ' + String(file).slice(0, 160)); e.code = 'NOT_FOUND'; throw e; }
3836
+ if (html.length > 1500000) html = html.slice(0, 1500000);
3837
+ const bands = cdpBandsFromHtml(html, raw);
3838
+ return { ...bands, source: 'local-file', note: 'local file estimate — tags/landmarks only, no measured layout; serve over http + allowLocal:true for live-dom bands' };
3839
+ };
3840
+ const isLocalInput = (raw) => typeof raw === 'string' && (/^file:\/\//i.test(raw) || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('/') || /^[^/:?#]+\.html?([?#]|$)/i.test(raw));
3841
+ const fetchStatic = async (target) => {
3842
+ const res = await globalThis.fetch(target, { headers: { 'user-agent': 'aura-components-mcp/1.7.2' }, redirect: 'follow', signal: AbortSignal.timeout(10000) });
3843
+ if (res.status === 404) { const e = new Error('page not found: ' + target); e.code = 'NOT_FOUND'; throw e; }
3844
+ if (!res.ok) throw awardErr('UPSTREAM_ERROR', 'HTTP ' + res.status + ' for ' + target);
3845
+ const text = await res.text();
3846
+ return text.slice(0, 1500000);
3847
+ };
3848
+ const structureOf = async (rawTarget, label) => {
3849
+ if (isLocalInput(rawTarget)) return readLocalSource(rawTarget, label || 'url');
3850
+ const target = rawTarget;
3851
+ if (awardBrowser) {
3852
+ try { return await awardBrowser.structure(target, { viewport, allowLocal }); }
3853
+ catch (e) {
3854
+ // Any live-dom failure degrades to labeled static estimates —
3855
+ // the audit stays usable, and the downgrade is disclosed.
3856
+ if (e && (e.code === 'BROWSER_UNAVAILABLE' || e.code === 'CAPTURE_TIMEOUT' || e.code === 'UPSTREAM_ERROR')) {
3857
+ try {
3858
+ const html = await fetchStatic(target);
3859
+ return { ...cdpBandsFromHtml(html, target), fallbackFrom: 'live-dom', fallbackWhy: String((e && e.message) || e).slice(0, 200) };
3860
+ } catch (fe) {
3861
+ const both = awardErr('UPSTREAM_ERROR', 'live-dom failed (' + String((e && e.message) || e).slice(0, 160) + ') AND static fetch failed (' + String((fe && fe.message) || fe).slice(0, 160) + ') — is the dev server running? For http://127.0.0.1 targets serve the build first (e.g. npx serve) with allowLocal:true; for files pass the path directly with allowLocal:true.');
3862
+ throw both;
3863
+ }
3864
+ }
3865
+ throw e;
3866
+ }
3867
+ }
3868
+ const html = await fetchStatic(target);
3869
+ return cdpBandsFromHtml(html, target);
3870
+ };
3871
+ if (mode === 'capture') {
3872
+ if (typeof a.url === 'string' && isLocalInput(a.url) && !/^https?:\/\//i.test(a.url)) toolFn_bad('capture needs an http(s) URL — screenshots require a browser navigating a served page. Serve the build (e.g. npx serve) and pass its http://127.0.0.1:PORT URL with allowLocal:true.');
3873
+ const target = awardValidateHttpUrl(a.url || toolFn_bad('url (string) is required'), { allowLocal });
3874
+ if (!awardBrowser) throw awardErr('BROWSER_UNAVAILABLE', 'capture needs Chrome: install it or set AURA_CHROME_PATH. Fallback: aura_visual_audit {mode:structure} estimates sections from static HTML without a browser.');
3875
+ try { return textResult(await awardBrowser.capture(target, { viewport, waitStrategy, settleMs, allowLocal })); }
3876
+ catch (e) {
3877
+ if (e && e.code === 'BROWSER_UNAVAILABLE') throw e;
3878
+ throw awardErr('UPSTREAM_ERROR', 'capture failed: ' + String((e && e.message) || e).slice(0, 220) + ' — retry, or use mode:structure for a static estimate');
3879
+ }
3880
+ }
3881
+ if (mode === 'structure') {
3882
+ const raw = a.url || toolFn_bad('url (string) is required');
3883
+ const target = isLocalInput(raw) ? raw : awardValidateHttpUrl(raw, { allowLocal });
3884
+ return textResult({ mode, ...(await structureOf(target, 'url')) });
3885
+ }
3886
+ const rawRef = a.referenceUrl || toolFn_bad('referenceUrl (string) is required');
3887
+ const rawBuild = a.buildUrl || toolFn_bad('buildUrl (string) is required');
3888
+ const normRef = isLocalInput(rawRef) ? rawRef : awardValidateHttpUrl(rawRef, { allowLocal });
3889
+ const normBuild = isLocalInput(rawBuild) ? rawBuild : awardValidateHttpUrl(rawBuild, { allowLocal });
3890
+ const [rs, bs] = await Promise.all([structureOf(normRef, 'referenceUrl'), structureOf(normBuild, 'buildUrl')]);
3891
+ const mismatches = [];
3892
+ if (rs.bands.length !== bs.bands.length) mismatches.push({ kind: 'section-count', detail: 'reference has ' + rs.bands.length + ' bands, build has ' + bs.bands.length });
3893
+ const seq = (x) => x.bands.map((b) => b.tag + ':' + (b.label || '')).join(' > ');
3894
+ if (seq(rs) !== seq(bs)) mismatches.push({ kind: 'section-order', detail: 'band tag sequence differs', reference: seq(rs).slice(0, 300), build: seq(bs).slice(0, 300) });
3895
+ if (rs.totalHeight && bs.totalHeight) {
3896
+ const ratio = bs.totalHeight / Math.max(1, rs.totalHeight);
3897
+ if (ratio < 0.7 || ratio > 1.4) mismatches.push({ kind: 'page-height', detail: 'build height ' + bs.totalHeight + 'px vs reference ' + rs.totalHeight + 'px (ratio ' + ratio.toFixed(2) + ')' });
3898
+ }
3899
+ const pal = (x) => new Set(x.bands.map((b) => String(b.background || '').toLowerCase()).filter(Boolean));
3900
+ const rp = pal(rs), bp = pal(bs);
3901
+ const onlyRef = [...rp].filter((c) => ![...bp].some((d) => d === c));
3902
+ if (onlyRef.length >= 2) mismatches.push({ kind: 'palette', detail: onlyRef.length + ' reference backgrounds absent in build: ' + onlyRef.slice(0, 4).join(', ') });
3903
+ return textResult({ mode, reference: rs, build: bs, mismatches, manualJudgment: ['imagery tone and spacing rhythm need human eyes', 'motion/interaction parity is not covered here — see aura_motion_study'], metrics: { refBands: rs.bands.length, buildBands: bs.bands.length }, sources: [rs.source, bs.source], ...((rs.source !== 'live-dom' || bs.source !== 'live-dom') ? { estimateNote: 'one or both sides are layout estimates (no measured offsets) — counts/order still comparable, heights are not' } : {}) }); },
3904
+ aura_motion_study: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['inspect', 'record', 'search'], 'mode');
3905
+ if (mode === 'search') {
3906
+ const r = awardIndex.searchMotion({ library: toolFn_str(a.library, 'library'), easing: toolFn_str(a.easing, 'easing'), scrollModel: a.scrollModel === undefined ? undefined : String(a.scrollModel), limit: awardLimit(a.frames, 10, 25) });
3907
+ if (r.empty) return textResult({ mode, total: 0, items: [], emptyCorpus: true, note: 'no motion studies stored yet' });
3908
+ return textResult({ mode, total: r.total, items: r.items, emptyCorpus: false });
3909
+ }
3910
+ const allowLocal = a.allowLocal === undefined ? false : toolFn_bool(a.allowLocal, 'allowLocal');
3911
+ const viewport = a.viewport === undefined ? 'desktop' : awardMode(a.viewport, ['desktop', 'mobile'], 'viewport');
3912
+ const target = awardValidateHttpUrl(a.url || toolFn_bad('url (string) is required'), { allowLocal });
3913
+ if (!awardBrowser) throw awardErr('BROWSER_UNAVAILABLE', 'motion ' + mode + ' needs Chrome: install it or set AURA_CHROME_PATH. Fallback motion plan (generic, NOT observed): entrances <500ms ease-out; scroll reveals via IntersectionObserver; hover 1.03 + shadow; easing ease-out/cubic-bezier(.22,1,.36,1); prefers-reduced-motion disables parallax/scrub. aura_motion_study {mode:search} still queries stored studies.');
3914
+ if (mode === 'inspect') {
3915
+ const study = await awardBrowser.motionInspect(target, { viewport, allowLocal });
3916
+ try { awardIndex.recordMotion(study); } catch { /* memory fallback never fails */ }
3917
+ return textResult({ mode, study });
3918
+ }
3919
+ const frames = a.frames === undefined ? 8 : toolFn_num(a.frames, 'frames');
3920
+ if (typeof frames !== 'number' || frames < 2 || frames > 16) toolFn_bad('frames must be 2-16');
3921
+ const durationMs = a.durationMs === undefined ? 12000 : toolFn_num(a.durationMs, 'durationMs');
3922
+ if (typeof durationMs !== 'number' || durationMs < 4000 || durationMs > 30000) toolFn_bad('durationMs must be 4000-30000');
3923
+ const rec = await awardBrowser.recordFrames(target, { frames: Math.floor(frames), viewport, allowLocal, durationMs: Math.floor(durationMs) });
3924
+ if (rec.study) { try { awardIndex.recordMotion(rec.study); } catch { /* ignore */ } }
3925
+ let video = null, videoAvailable = false, videoHint = null;
3926
+ try {
3927
+ const enc = await awardBrowser.encodeWebm(rec.frames, {});
3928
+ video = enc.video; videoAvailable = !!enc.available; videoHint = enc.hint || null;
3929
+ } catch (e) { videoHint = String((e && e.message) || e).slice(0, 160); }
3930
+ if (!videoAvailable && !videoHint) videoHint = 'ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output — frame JPEGs retained';
3931
+ return textResult({ mode, url: target, study: rec.study, frames: rec.frames, frameCount: rec.frameCount, video, video_available: videoAvailable, ...(videoHint ? { videoHint } : {}) }); },
3932
+ aura_award_index: async (a) => { a = a || {}; const action = awardMode(a.action, ['status', 'rebuild', 'resume', 'refresh', 'new_winners', 'watch_add', 'watch_list', 'watch_remove'], 'action');
3933
+ if (action === 'status') return textResult({ action, ...awardIndex.getStatus() });
3934
+ if (action === 'watch_list') return textResult({ action, watches: awardIndex.watchList() });
3935
+ if (action === 'watch_add') {
3936
+ const kind = awardMode(a.kind, ['studio', 'tag', 'url'], 'kind');
3937
+ if (typeof a.pattern !== 'string' || !a.pattern.trim()) toolFn_bad('pattern (string) is required');
3938
+ return textResult({ action, watch: awardIndex.watchAdd({ kind, pattern: a.pattern, award: a.award }) });
3939
+ }
3940
+ if (action === 'watch_remove') {
3941
+ const kind = awardMode(a.kind, ['studio', 'tag', 'url'], 'kind');
3942
+ if (typeof a.pattern !== 'string' || !a.pattern.trim()) toolFn_bad('pattern (string) is required');
3943
+ return textResult({ action, ...awardIndex.watchRemove({ kind, pattern: a.pattern }) });
3944
+ }
3945
+ if (action === 'new_winners') {
3946
+ if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', 'new_winners needs an award client (server wiring)');
3947
+ const r = await awardIndex.newWinners({ client: awardClient, award: a.award || 'sotd' });
3948
+ let deltas = [];
3949
+ try { deltas = awardIndex.checkWatches ? awardIndex.checkWatches() : []; } catch { deltas = []; }
3950
+ return textResult({ action, ...r, watchDeltas: deltas });
3951
+ }
3952
+ if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', action + ' needs an award client (server wiring)');
3953
+ const maxPages = a.maxPages === undefined ? 5 : toolFn_num(a.maxPages, 'maxPages');
3954
+ if (typeof maxPages !== 'number' || maxPages < 1 || maxPages > 20) toolFn_bad('maxPages must be 1-20 (bounded crawl per call; resume for more)');
3955
+ // Bounded crawl: cap the taxonomy to maxPages per call, keep resume state.
3956
+ const tax = awardIndex.getTaxonomy();
3957
+ const tags = (tax.filters || []).slice(0, Math.floor(maxPages));
3958
+ const r = action === 'refresh' ? await awardIndex.refresh({ client: awardClient, tags }) : action === 'resume' ? await awardIndex.resume({ client: awardClient, tags: tags.length ? tags : undefined }) : await awardIndex.rebuild({ client: awardClient, tags: tags.length ? tags : undefined });
3959
+ return textResult({ action, ...r, note: 'bounded per call (maxPages); call resume to continue' }); },
1884
3960
  };
1885
3961
  };
1886
3962
 
@@ -1896,12 +3972,22 @@ function createHandlers(catalog, icons, version) {
1896
3972
 
1897
3973
 
1898
3974
 
3975
+
3976
+
3977
+
1899
3978
  const config = loadConfig();
1900
3979
  const fetcher = createFetcher({ fetchImpl: globalThis.fetch, timeoutMs: config.timeoutMs, retries: config.retries, userAgent: config.userAgent });
1901
3980
  const catalog = createCatalog({ fetcher, config, facetsFn: facets });
1902
3981
  const icons = createIcons({ fetcher });
1903
3982
  const version = createVersion({ fetcher, running: SERVER_INFO.version });
1904
- const handlers = createHandlers(catalog, icons, version);
3983
+ // Award subsystem: persistent file index, polite live client, optional CDP
3984
+ // browser. No auto-crawl here — indexing runs only via aura_award_index.
3985
+ const awardClient = createAwardClient({ fetchImpl: globalThis.fetch });
3986
+ let awardIndex = null;
3987
+ try { awardIndex = createAwardIndex({}); } catch { awardIndex = null; }
3988
+ let awardBrowser = null;
3989
+ try { awardBrowser = createBrowserAdapter({}); } catch { awardBrowser = null; }
3990
+ const handlers = createHandlers(catalog, icons, version, { awardClient, awardIndex, browser: awardBrowser });
1905
3991
 
1906
3992
  let buffer = "";
1907
3993
  const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");