aura-components-mcp 1.7.0 → 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.0 — 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.0",
33
+ userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.2",
34
34
  };
35
35
  }
36
36
 
@@ -59,6 +59,9 @@ function createFetcher({ fetchImpl, timeoutMs, retries, userAgent }) {
59
59
  }
60
60
 
61
61
  // GET JSON over PostgREST. Returns { rows, total } (total from content-range).
62
+ // Retryable: 429/5xx, network errors, timeouts (except AbortError: hung
63
+ // requests are not retried). Non-retryable: other 4xx (invalid requests
64
+ // never succeed on retry) — thrown immediately with noRetry set.
62
65
  async function getJson(url, headers) {
63
66
  let lastErr;
64
67
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -68,7 +71,9 @@ function createFetcher({ fetchImpl, timeoutMs, retries, userAgent }) {
68
71
  lastErr = new Error("transient upstream status " + res.status);
69
72
  } else if (!res.ok) {
70
73
  const body = await res.text().catch(() => "");
71
- throw new Error("upstream " + res.status + ": " + body.slice(0, 300));
74
+ const e = new Error("upstream " + res.status + ": " + body.slice(0, 300));
75
+ if (res.status >= 400 && res.status < 500) e.noRetry = true;
76
+ throw e;
72
77
  } else {
73
78
  const range = res.headers?.get?.("content-range") ?? null;
74
79
  const total = range && range.includes("/") ? Number(range.split("/").pop()) : null;
@@ -76,8 +81,8 @@ function createFetcher({ fetchImpl, timeoutMs, retries, userAgent }) {
76
81
  return { rows: Array.isArray(rows) ? rows : [rows], total: Number.isFinite(total) ? total : null };
77
82
  }
78
83
  } catch (err) {
84
+ if (err && (err.noRetry || err.name === "AbortError")) throw err;
79
85
  lastErr = err;
80
- if (err && err.name === "AbortError") break; // timeout: retrying rarely helps
81
86
  }
82
87
  if (attempt < retries) await sleep(120 * 2 ** attempt + Math.floor(Math.random() * 60));
83
88
  }
@@ -145,15 +150,31 @@ const DETAIL_COLS = {
145
150
  };
146
151
 
147
152
  const esc = (s) => String(s).replaceAll('"', '""');
148
- 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) + "*";
149
163
 
150
164
  // Split a goal sentence into significant tokens for OR fallback (stop-word filtered).
151
165
  // Theme words (dark/light) are NOT search tokens — they become rank/filter signals
152
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.
153
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(","));
154
172
  const THEME_WORDS = new Set(["dark", "light", "midnight", "noir", "black", "white", "bright", "airy", "minimal"]);
155
173
  function queryTokens(q, max = 4) {
156
- 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);
157
178
  }
158
179
  // Theme hint from the raw query: explicit signal, separate from match tokens.
159
180
  function detectThemeHint(q) {
@@ -164,6 +185,19 @@ function detectThemeHint(q) {
164
185
  if (light && !dark) return "light";
165
186
  return null;
166
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
+ }
167
201
  // Rank rows: +2 per matched token in title, +1 in description/tags, theme match first.
168
202
  // Returns { rows, scored } where scored explains the top pick for fallback_score.
169
203
  function scoreRows(kind, rows, toks, themeHint) {
@@ -172,7 +206,11 @@ function scoreRows(kind, rows, toks, themeHint) {
172
206
  const desc = String(r.description || "").toLowerCase();
173
207
  const tags = ((r.tags || []).map((x) => String(x).toLowerCase()).join(" "));
174
208
  let s = 0;
175
- 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
+ }
176
214
  const th = r.facets && r.facets.theme;
177
215
  const themeOk = !themeHint || th === themeHint || th === "mixed" || th === "unknown";
178
216
  if (themeHint && th === themeHint) s += 3;
@@ -193,7 +231,8 @@ function matchOverlap(kind, toks, item) {
193
231
  let n = 0;
194
232
  for (const t of list) {
195
233
  const tok = String(t ?? "").toLowerCase();
196
- if (tok && title.includes(tok)) n++;
234
+ const stem = stemTok(tok);
235
+ if (tok && (title.includes(tok) || (stem !== tok && title.includes(stem)))) n++;
197
236
  }
198
237
  return n;
199
238
  }
@@ -206,7 +245,8 @@ function matchOverlap(kind, toks, item) {
206
245
  let n = 0;
207
246
  for (const t of list) {
208
247
  const tok = String(t ?? "").toLowerCase();
209
- if (tok && hay.includes(tok)) n++;
248
+ const stem = stemTok(tok);
249
+ if (tok && (hay.includes(tok) || (stem !== tok && hay.includes(stem)))) n++;
210
250
  }
211
251
  return n;
212
252
  }
@@ -280,6 +320,39 @@ function slimRows(kind, items) {
280
320
  });
281
321
  }
282
322
 
323
+ // Central access policy: only components/assets carry a premium flag, and a
324
+ // row is free ⟺ premium === false (fail-closed: missing or ambiguous
325
+ // entitlement is refused, never assumed public). Skills/design_systems
326
+ // have no premium concept and are always allowed. Every retrieval path
327
+ // (getItem, and therefore bundle/bulk/scaffold/related/install) funnels
328
+ // through assertFreeRow, so callers cannot reach restricted content by ID.
329
+ function isFreeRow(kind, row) {
330
+ if (kind !== "components" && kind !== "assets") return true;
331
+ return !!row && row.premium === false;
332
+ }
333
+
334
+ function assertFreeRow(kind, row) {
335
+ if (isFreeRow(kind, row)) return;
336
+ const what = kind === "assets" ? "asset" : "component";
337
+ const e = new Error("that " + what + " is Pro (paid). Search with freeOnly or pick a free one.");
338
+ e.code = "BAD_ARGS";
339
+ throw e;
340
+ }
341
+
342
+ // Row-shape gate: the minimum fields every surface needs to build a usable
343
+ // item (identity for page_url + title for display/matching). Rows that fail
344
+ // are upstream schema drift, not empty results: listings disclose them via
345
+ // malformed_dropped, single-record fetch fails loud. Never silently shaped
346
+ // into "undefined" URLs or untitled items.
347
+ function isWellFormedRow(kind, row) {
348
+ if (!row || typeof row !== "object") return false;
349
+ if (typeof row.title !== "string" || !row.title) return false;
350
+ const hasId = row.id !== undefined && row.id !== null;
351
+ const hasSlug = typeof row.slug === "string" && !!row.slug;
352
+ if (kind === "components" || kind === "design_systems") return hasId || hasSlug;
353
+ return hasId;
354
+ }
355
+
283
356
  function pageUrl(kind, row) {
284
357
  if (kind === "components") return SITE + "/component/" + row.slug;
285
358
  if (kind === "skills") return SITE + "/skills/" + row.id;
@@ -364,7 +437,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
364
437
  const authorCache = new Map(); // created_by -> profile (shared across searches)
365
438
  const inflight = new Map(); // cacheKey -> Promise (coalesce concurrent identical calls)
366
439
 
367
- const clampLimit = (l) => Math.min(config.maxLimit, Math.max(1, l ?? config.defaultLimit));
440
+ const clampLimit = (l) => Math.min(config.maxLimit, Math.max(1, Math.floor(l ?? config.defaultLimit)));
368
441
 
369
442
  async function enrichAuthors(rows) {
370
443
  const ids = [...new Set(rows.map((r) => r.created_by).filter(Boolean).filter((id) => !authorCache.has(id)))];
@@ -384,12 +457,15 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
384
457
  const sort = CATALOG_SORTS[kind][q.sort] ? q.sort : "popular";
385
458
  const freeOnly = q.freeOnly ?? config.freeOnlyDefault;
386
459
  // Gap 1: negative_theme (exclude a theme) + min_views (quality floor). Both ride
387
- // the cache key so filtered/unfiltered variants never collide.
460
+ // the cache key so filtered/unfiltered variants never collide. Explicit
461
+ // theme also rides the key: it re-ranks results, so dark/light variants
462
+ // must never share a cache entry or an in-flight request.
463
+ const themeKey = q.theme ? String(q.theme).toLowerCase() : "";
388
464
  const negTheme = q.negative_theme ? String(q.negative_theme).toLowerCase() : "";
389
465
  const minViews = Math.max(0, q.min_views ?? 0);
390
466
  const slim = q.slim ?? true;
391
467
  const exactFlag = q.exact_match === true ? "exact" : "fuzzy";
392
- const key = ["search", kind, q.query ?? "", q.tag ?? "", q.mediaType ?? "", sort, limit, offset, freeOnly, negTheme, minViews, slim ? "slim" : "full", exactFlag].join("|");
468
+ const key = ["search", kind, q.query ?? "", q.tag ?? "", q.mediaType ?? "", sort, limit, offset, freeOnly, themeKey, negTheme, minViews, slim ? "slim" : "full", exactFlag].join("|");
393
469
  const hit = cacheGet(key);
394
470
  if (hit) return { ...hit, cached: true };
395
471
  if (inflight.has(key)) return inflight.get(key);
@@ -476,7 +552,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
476
552
  if ((kind === "design_systems" || kind === "skills") && rows.length) {
477
553
  const topIds = rows.slice(0, 5).map((r) => r.id);
478
554
  try {
479
- const idList = topIds.map((id) => encodeURIComponent(String(id))).join(",");
555
+ const idList = topIds.map((id) => escEq(id)).join(",");
480
556
  const sel = kind === "design_systems" ? "id,content,preview_html" : "id,content,source_url";
481
557
  const { rows: full } = await fetcher.getJson(base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&id=in.(" + idList + ")", headers);
482
558
  const byId = new Map(full.map((r) => [String(r.id), r]));
@@ -490,11 +566,28 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
490
566
  } catch { /* snippets are best-effort; rows still return */ }
491
567
  }
492
568
  const enriched = await enrichAuthors(rows);
493
- const out = { kind, total, limit, offset, items: enriched.map((r) => shapeRow(kind, r, config, facetsFn)), cached: false };
569
+ // Schema-drift gate: malformed rows are disclosed, never shaped into
570
+ // untitled items or ".../undefined" page URLs, and never counted as
571
+ // successful empty results (total keeps its upstream meaning).
572
+ const formed = [];
573
+ let malformed = 0;
574
+ for (const r of enriched) {
575
+ if (isWellFormedRow(kind, r)) formed.push(r);
576
+ else malformed++;
577
+ }
578
+ const out = { kind, total, limit, offset, items: formed.map((r) => shapeRow(kind, r, config, facetsFn)), cached: false };
579
+ if (malformed) out.malformed_dropped = malformed;
494
580
  if (deduped) out.deduped = deduped;
495
581
  // Gap 1: min_views quality floor (views-gated surfaces only) + negative_theme
496
582
  // hard exclusion. Both apply before theme re-rank so counts stay honest.
497
- if (minViews > 0) out.items = out.items.filter((it) => (it.views || 0) >= minViews);
583
+ // total keeps its upstream meaning (all matches for limit/offset paging);
584
+ // local drops are disclosed via min_views_filtered_out / deduped /
585
+ // negative_theme_filtered_out instead of rewriting total.
586
+ if (minViews > 0) {
587
+ const beforeViews = out.items.length;
588
+ out.items = out.items.filter((it) => (it.views || 0) >= minViews);
589
+ if (out.items.length < beforeViews) out.min_views_filtered_out = beforeViews - out.items.length;
590
+ }
498
591
  if (negTheme && kind === "components") {
499
592
  const before = out.items.length;
500
593
  out.items = out.items.filter((it) => !it.facets || it.facets.theme !== negTheme);
@@ -534,14 +627,24 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
534
627
  if (fallback === "popular-safety-net") out.suggested_queries = suggestList();
535
628
  }
536
629
  else if ((!rows || !rows.length) && toks.length) { out.isFallback = false; out.suggested_queries = suggestList(); }
537
- // Zero-overlap refusal: fallback engaged but top row matches 0/N tokens —
538
- // honest empty instead of irrelevant popular rows. Direct hits never refused.
539
- // Kind-aware: components/assets stay title-only strict; skills/systems use the
540
- // 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.
541
639
  if (fallback != null && toks.length > 0) {
542
- const top = out.items[0];
543
- const hits2 = matchOverlap(kind, toks, top);
544
- 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) {
545
648
  const refused = fallback;
546
649
  out.items = [];
547
650
  out.total = 0;
@@ -552,6 +655,9 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
552
655
  out.refused_fallback = refused;
553
656
  out.suggested_queries = suggestList();
554
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 : "");
555
661
  }
556
662
  }
557
663
  if (slim) out.items = slimRows(kind, out.items);
@@ -573,13 +679,15 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
573
679
  const sel = DETAIL_COLS[kind];
574
680
  // design_systems + skills: uuid id vs slug; components: numeric id vs slug
575
681
  const filt = (kind === "design_systems" || kind === "skills")
576
- ? (isUuid ? "id=eq." + encodeURIComponent(String(idOrSlug)) : "slug=eq." + encodeURIComponent(String(idOrSlug)))
682
+ ? (isUuid ? "id=eq." + escEq(idOrSlug) : "slug=eq." + escEq(idOrSlug))
577
683
  : (kind === "components" && !isNum
578
- ? "slug=eq." + encodeURIComponent(String(idOrSlug))
579
- : "id=eq." + encodeURIComponent(String(idOrSlug)));
684
+ ? "slug=eq." + escEq(idOrSlug)
685
+ : "id=eq." + escEq(idOrSlug));
580
686
  const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&" + filt + "&limit=1";
581
687
  const { rows } = await fetcher.getJson(url, headers);
582
688
  if (!rows.length) { const e = new Error("not found: " + kind + " " + idOrSlug); e.code = "NOT_FOUND"; throw e; }
689
+ assertFreeRow(kind, rows[0]); // before enrich/cache: refusals are never cached
690
+ if (!isWellFormedRow(kind, rows[0])) throw new Error("malformed upstream row for " + kind + " " + idOrSlug + ": missing identity/title fields");
583
691
  const enriched = await enrichAuthors(rows);
584
692
  const full = enriched[0];
585
693
  // Chunked fetch: re-slice full blobs per requested chunk so agents can page
@@ -690,13 +798,15 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
690
798
  try {
691
799
  const { rows } = await fetcher.getJson(url, headers);
692
800
  const enriched = await enrichAuthors(rows);
693
- return enriched.map((r) => slimRows("components", [shapeRow("components", r, config, facetsFn)])[0]);
801
+ return enriched.filter((r) => isWellFormedRow("components", r)).map((r) => slimRows("components", [shapeRow("components", r, config, facetsFn)])[0]);
694
802
  } catch { return []; }
695
803
  }
696
804
 
697
805
  // Bulk fetch: N details in one parallel round (backs aura_bundle + aura_scaffold_page).
698
- // Per-id errors are captured, never thrown: { ok:true, item } or { ok:false, id, error }.
699
- async function bundleItems(kind, ids, max = 5, opts = {}) {
806
+ // Limit is 8 end-to-end (matches normRefs validation and the 2–8 docs):
807
+ // duplicates collapse to one fetch, order follows the request, and
808
+ // per-id errors are captured, never thrown: { ok:true, item } or { ok:false, id, error }.
809
+ async function bundleItems(kind, ids, max = 8, opts = {}) {
700
810
  const list = [...new Set((ids || []).map(String))].slice(0, Math.max(1, Math.min(max, 8)));
701
811
  const out = await Promise.all(list.map(async (id) => {
702
812
  try { const got = await getItem(kind, id, { full: !!opts.full }); return { ok: true, id, item: got.item }; }
@@ -716,13 +826,14 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
716
826
  const lim = Math.max(1, Math.min(limit || 3, 8));
717
827
  const sel = LIST_COLS[kind];
718
828
  const idCol = (kind === "components") ? "id" : "id";
719
- const selfId = encodeURIComponent(String(item.id));
829
+ const selfId = escEq(item.id);
720
830
  const premF = (freeOnly && (kind === "components" || kind === "assets")) ? "&premium=eq.false" : "";
721
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;
722
832
  try {
723
833
  const { rows } = await fetcher.getJson(url, headers);
724
834
  const enriched = await enrichAuthors(rows);
725
- return { item: shapeRow(kind, item, config), related: slimRows(kind, enriched.map((r) => shapeRow(kind, r, config))) };
835
+ const wellFormed = enriched.filter((r) => isWellFormedRow(kind, r));
836
+ return { item: shapeRow(kind, item, config), related: slimRows(kind, wellFormed.map((r) => shapeRow(kind, r, config))) };
726
837
  } catch { return { item: shapeRow(kind, item, config), related: [] }; }
727
838
  }
728
839
 
@@ -730,7 +841,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
730
841
  }
731
842
 
732
843
  // ---- protocol.mjs ----
733
- const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.0" };
844
+ const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.2" };
734
845
  // Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
735
846
  // One interface: createSession(send) -> { dispatch(msg) }. No business logic.
736
847
 
@@ -1484,11 +1595,17 @@ function createIcons({ fetcher, now = () => Date.now() }) {
1484
1595
  if (hit) cache.delete(key);
1485
1596
 
1486
1597
  const tried = [];
1598
+ let lastFetchErr = null;
1487
1599
  for (const c of iconCandidates(raw, source, style)) {
1488
1600
  let body = null;
1489
1601
  try {
1490
1602
  body = await fetcher.getText(c.url, {});
1491
- } catch {
1603
+ } catch (e) {
1604
+ // Network-level failure is not a miss: remember it, keep walking the
1605
+ // chain in case only one host is down, and rethrow at the end if
1606
+ // nothing else explains the failure (never disguise an outage as
1607
+ // "not found").
1608
+ lastFetchErr = e instanceof Error ? e : new Error(String(e));
1492
1609
  tried.push({ url: c.url, ok: false, why: "fetch-error" });
1493
1610
  continue;
1494
1611
  }
@@ -1510,6 +1627,7 @@ function createIcons({ fetcher, now = () => Date.now() }) {
1510
1627
  while (cache.size > ICON_MAX_KEYS) cache.delete(cache.keys().next().value);
1511
1628
  return out;
1512
1629
  }
1630
+ if (lastFetchErr && tried.every((t) => t.why === "fetch-error")) throw lastFetchErr;
1513
1631
  iconMissing("icon not found: '" + raw + "' (" + source + "/" + style + ") — checked " + tried.length + " URLs; check spelling (FA names use dashes, e.g. 'arrow-down')");
1514
1632
  }
1515
1633
 
@@ -1560,6 +1678,1551 @@ function createVersion({ fetcher, running, now = () => Date.now() }) {
1560
1678
  return { getVersion };
1561
1679
  }
1562
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
+
1563
3226
  // ---- tools.mjs ----
1564
3227
  // Module: tools — thin adapters. Each tool validates args at the seam,
1565
3228
  // calls one catalog/guide method, wraps textResult. No retries/caching here
@@ -1630,8 +3293,14 @@ const TOOL_DEFS = [
1630
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 } },
1631
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 } },
1632
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 } },
1633
3302
  ];
1634
- function createHandlers(catalog, icons, version) {
3303
+ function createHandlers(catalog, icons, version, awardDeps) {
1635
3304
  // Automatic update notice: every aura_status carries the cached version
1636
3305
  // verdict (hourly npm re-check), so agents learn about updates unprompted.
1637
3306
  const versionNote = async () => {
@@ -1640,6 +3309,81 @@ function createHandlers(catalog, icons, version) {
1640
3309
  } catch { /* status must never fail on version check */ }
1641
3310
  return null;
1642
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
+ }
1643
3387
  return {
1644
3388
  aura_status: async () => {
1645
3389
  const [status, update] = await Promise.all([catalog.getStatus(), versionNote()]);
@@ -1744,7 +3488,7 @@ function createHandlers(catalog, icons, version) {
1744
3488
  aura_install_component: async (a) => { a = a || {}; const id = a.id !== undefined ? a.id : a.slug;
1745
3489
  if (id === undefined || (typeof id !== 'string' && typeof id !== 'number')) toolFn_bad('provide id (number) or slug (string)');
1746
3490
  const got = await catalog.getItem('components', id);
1747
- if (got.item && got.item.premium !== false) toolFn_bad('that component is Pro (paid). Search with freeOnly or pick a free one.');
3491
+ // Pro refusal enforced centrally in catalog.getItem (fail-closed).
1748
3492
  const compInstall = installGuide('components', got.item);
1749
3493
  return textResult({ item: got.item, install: compInstall, docs_stamp: docsStampFor(compInstall.deps) }); },
1750
3494
  aura_install_skill: async (a) => { a = a || {}; if (typeof a.id !== 'string' || !a.id) toolFn_bad('id (string) is required');
@@ -1799,17 +3543,420 @@ function createHandlers(catalog, icons, version) {
1799
3543
  const compRefs = normRefs(a.components, 'components') || [];
1800
3544
  const full = a.full === undefined ? undefined : toolFn_bool(a.full, 'full');
1801
3545
  const fullOpt = full === undefined ? {} : { full };
1802
- const [sys, comps] = await Promise.all([
3546
+ const [sys, bundle] = await Promise.all([
1803
3547
  sysRef ? catalog.getItem('design_systems', sysRef, fullOpt).catch(() => null) : catalog.searchCatalog('design_systems', { query: a.goal, limit: 1 }).then((r) => (r.items[0] ? { item: r.items[0] } : null)),
1804
- compRefs.length ? catalog.bundleItems('components', compRefs, 5, fullOpt).then((rs) => rs.filter((x) => x.ok).map((x) => x.item)) : catalog.searchCatalog('components', { query: a.goal, freeOnly: true, limit: 3 }).then((r) => r.items),
3548
+ compRefs.length ? catalog.bundleItems('components', compRefs, 8, fullOpt).then((rs) => ({ items: rs.filter((x) => x.ok).map((x) => x.item), dropped: rs.filter((x) => !x.ok).length })) : catalog.searchCatalog('components', { query: a.goal, freeOnly: true, limit: 3 }).then((r) => ({ items: r.items, dropped: 0 })),
1805
3549
  ]);
3550
+ const comps = bundle.items;
1806
3551
  // Pro guard: explicit-id bundles can name Pro rows — free flows never ship them.
1807
3552
  const freeComps = (comps || []).filter((c) => c && c.premium === false);
1808
3553
  const proFiltered = (comps || []).length - freeComps.length;
1809
3554
  const sysItem = sys && sys.item ? sys.item : null;
1810
3555
  const toks = sysItem ? tokenHints(sysItem.content || '') : { tokens: {}, css: '' };
1811
3556
  const { guides, combined_deps: deps, files } = combineScaffold(sysItem, freeComps);
1812
- 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), ...(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' }); },
1813
3960
  };
1814
3961
  };
1815
3962
 
@@ -1825,12 +3972,22 @@ function createHandlers(catalog, icons, version) {
1825
3972
 
1826
3973
 
1827
3974
 
3975
+
3976
+
3977
+
1828
3978
  const config = loadConfig();
1829
3979
  const fetcher = createFetcher({ fetchImpl: globalThis.fetch, timeoutMs: config.timeoutMs, retries: config.retries, userAgent: config.userAgent });
1830
3980
  const catalog = createCatalog({ fetcher, config, facetsFn: facets });
1831
3981
  const icons = createIcons({ fetcher });
1832
3982
  const version = createVersion({ fetcher, running: SERVER_INFO.version });
1833
- 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 });
1834
3991
 
1835
3992
  let buffer = "";
1836
3993
  const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");