awwwards-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,18 +9,22 @@ sourced from the web's best award-winning websites.
9
9
  Your agent searches in natural language ("dark 3D portfolio sites", "soft pastel
10
10
  e-commerce"), sees **real screenshots inline**, and can pull the **design DNA**
11
11
  of any site: color palette, tech stack, design elements, award history.
12
+ Free-text queries run on a porter-stemmed, prefix-matching FTS5 index with BM25
13
+ ranking — "magazines" now finds Magazine-tagged sites (68 on the live index),
14
+ best matches first, where the old substring path returned zero. Multi-word
15
+ queries keep AND semantics: every token must hit the same site.
12
16
 
13
17
  ## Tools
14
18
 
15
19
  | Tool | What it does |
16
20
  |------|--------------|
17
- | `search_sites` | Search by color, tags, technology or award type. Returns site cards with inline screenshots. |
21
+ | `search_sites` | Search by color, tags, technology, award type or free-text query. Multi-word queries match against the local FTS5 index and rank BM25 (title hits lead); zero results come with loose-match and taxonomy-tag hints. Returns site cards with inline screenshots. |
18
22
  | `get_site_details` | Full design DNA for one site: palette, technologies, elements, awards, description. |
19
23
  | `get_site_elements` | Component-level visuals for one site: each element's poster image inline (3D models, video content, mobile layouts, microcopy…) + video URLs. |
20
24
  | `list_categories` | Every filter the agent can search by (200+ tags, 27 colors). |
21
- | `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
22
- | `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
23
- | `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. (needs [playwright](https://playwright.dev) + ffmpeg-static). |
25
+ | `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). Pass `viewport: "mobile"` for the 390×844 iPhone-class render (`"desktop"` 1440×900 default). (needs [playwright](https://playwright.dev)). |
26
+ | `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available); `viewport: "mobile"` analyzes the phone-class layout (`"desktop"` default). (needs [playwright](https://playwright.dev)). |
27
+ | `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. `viewport: "mobile"` records at phone size — the filmstrip renders at the selected viewport, no pillarboxing (`"desktop"` default). (needs [playwright](https://playwright.dev) + ffmpeg-static). |
24
28
 
25
29
  ## Setup
26
30
 
package/dist/cache.js CHANGED
@@ -17,6 +17,28 @@ const SCHEMA = `
17
17
  key TEXT PRIMARY KEY, value TEXT, fetchedAt INTEGER
18
18
  );
19
19
  `;
20
+ // FTS5 full-text layer over the sites table (derived — rebuildable at any
21
+ // time). Probe-guarded: if this Node build ships without FTS5, every fts
22
+ // statement is skipped and searchSites returns null (server keeps the legacy
23
+ // substring path). Columns mirror sites; tags/awards stay JSON strings —
24
+ // unicode61 tokenizes around brackets/quotes, so tokens extract cleanly.
25
+ const FTS_SCHEMA = `
26
+ CREATE VIRTUAL TABLE IF NOT EXISTS sites_fts USING fts5(
27
+ slug UNINDEXED, title, tags, awards, tokenize='porter unicode61'
28
+ );
29
+ CREATE TRIGGER IF NOT EXISTS sites_fts_ai AFTER INSERT ON sites BEGIN
30
+ INSERT INTO sites_fts (slug, title, tags, awards)
31
+ VALUES (new.slug, new.title, new.tags, new.awards);
32
+ END;
33
+ CREATE TRIGGER IF NOT EXISTS sites_fts_au AFTER UPDATE OF title, tags, awards ON sites BEGIN
34
+ DELETE FROM sites_fts WHERE slug = new.slug;
35
+ INSERT INTO sites_fts (slug, title, tags, awards)
36
+ VALUES (new.slug, new.title, new.tags, new.awards);
37
+ END;
38
+ CREATE TRIGGER IF NOT EXISTS sites_fts_ad AFTER DELETE ON sites BEGIN
39
+ DELETE FROM sites_fts WHERE slug = old.slug;
40
+ END;
41
+ `;
20
42
  function rowToSite(r) {
21
43
  return {
22
44
  slug: r.slug,
@@ -34,6 +56,12 @@ export class Cache {
34
56
  dbPath;
35
57
  now;
36
58
  imagesDir;
59
+ // FTS5 capability of this Node build, probed on the first DB open (the
60
+ // constructor's eager withDb). null = not yet probed; false = FTS5 compiled
61
+ // out → searchSites returns null and callers keep the legacy substring
62
+ // path. Instance-cached so searchSites never re-probes; public so the
63
+ // server layer and tests can branch on it.
64
+ ftsAvailable = null;
37
65
  constructor(rootDir, now = Date.now) {
38
66
  this.now = now;
39
67
  mkdirSync(rootDir, { recursive: true });
@@ -52,12 +80,40 @@ export class Cache {
52
80
  const db = new DatabaseSync(this.dbPath);
53
81
  try {
54
82
  db.exec(SCHEMA);
83
+ if (this.ftsAvailable === null) {
84
+ try {
85
+ db.exec(FTS_SCHEMA);
86
+ this.ftsAvailable = true;
87
+ }
88
+ catch {
89
+ this.ftsAvailable = false; // FTS5 compiled out → legacy fallback
90
+ }
91
+ }
92
+ if (this.ftsAvailable) {
93
+ // The fts table is derived and must never gate correctness: if sites
94
+ // has rows but sites_fts is empty (pre-FTS database opened for the
95
+ // first time), rebuild the index. The guard lives inside the INSERT
96
+ // itself (slug NOT IN sites_fts), not only in the counts above: the
97
+ // counts are read non-atomically, so `npm run index` and a first open
98
+ // can both pass them and both run this statement — sites_fts.slug has
99
+ // no unique constraint, so an unguarded re-run would double-index.
100
+ // Afterwards triggers keep it synced.
101
+ const { s: sitesN } = db.prepare("SELECT COUNT(*) AS s FROM sites").get();
102
+ const { s: ftsN } = db.prepare("SELECT COUNT(*) AS s FROM sites_fts").get();
103
+ if (sitesN > 0 && ftsN === 0) {
104
+ db.exec("INSERT INTO sites_fts (slug, title, tags, awards) SELECT slug, title, tags, awards FROM sites WHERE slug NOT IN (SELECT slug FROM sites_fts)");
105
+ }
106
+ }
55
107
  return fn(db);
56
108
  }
57
109
  finally {
58
110
  db.close();
59
111
  }
60
112
  }
113
+ /** @visibleForTesting */
114
+ withDbForTest(fn) {
115
+ this.withDb(fn);
116
+ }
61
117
  upsertSites(sites) {
62
118
  this.withDb((db) => {
63
119
  const stmt = db.prepare(`INSERT INTO sites (slug, id, title, createdAt, tags, thumbnailPath, liveUrl, detailPath, awards, fetchedAt)
@@ -99,6 +155,34 @@ export class Cache {
99
155
  return rowToSite(row);
100
156
  });
101
157
  }
158
+ // Full-text search over cached sites. matchMode "AND" (default) requires
159
+ // every token; "OR" matches rows containing any token (the server uses OR
160
+ // for its zero-result "loose matches" hint). Each token is a porter-stemmed
161
+ // prefix term, so multi-word queries match rows where the words are
162
+ // scattered across title/tags/awards. Results are bm25-ascending (best
163
+ // match first). Returns null when FTS5 is unavailable on this build or the
164
+ // query has no usable tokens — callers fall back to legacy search.
165
+ searchSites(query, maxAgeMs, limit = 200, matchMode = "AND") {
166
+ // Sanitization strips quotes/parens/operators, leaving [a-z0-9-] only —
167
+ // the quoted `"tok"*` MATCH string below cannot inject FTS syntax.
168
+ const tokens = query.toLowerCase().split(/\s+/)
169
+ .map((t) => t.replace(/[^a-z0-9-]/g, ""))
170
+ .filter((t) => t.length > 0);
171
+ if (!tokens.length)
172
+ return null;
173
+ return this.withDb((db) => {
174
+ if (!this.ftsAvailable)
175
+ return null;
176
+ const match = tokens.map((t) => `"${t}"*`).join(matchMode === "OR" ? " OR " : " AND ");
177
+ const min = this.now() - maxAgeMs;
178
+ const rows = db.prepare(`SELECT s.* FROM sites_fts
179
+ JOIN sites s ON s.slug = sites_fts.slug
180
+ WHERE sites_fts MATCH ? AND s.fetchedAt > ?
181
+ ORDER BY bm25(sites_fts) ASC
182
+ LIMIT ?`).all(match, min, limit);
183
+ return rows.map(rowToSite);
184
+ });
185
+ }
102
186
  setMeta(key, value) {
103
187
  this.withDb((db) => {
104
188
  db.prepare(`INSERT INTO meta (key, value, fetchedAt) VALUES (?, ?, ?)
package/dist/capture.js CHANGED
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  // CAPTURE_INSTALL_HINT from here) — both sides only use the other's bindings
7
7
  // at call time, which ESM resolves fine.
8
8
  import { preScroll } from "./structure.js";
9
+ import { resolveViewport } from "./viewport.js";
9
10
  export const CAPTURE_INSTALL_HINT = "Full-page capture needs Playwright, which is an optional dependency.\n" +
10
11
  "Install it with: npm install -D playwright && npx playwright install chromium\n" +
11
12
  "Then retry the capture or structure tool.";
@@ -29,7 +30,12 @@ loader = () => import("playwright"), opts) {
29
30
  }
30
31
  try {
31
32
  const waitStrategy = opts?.waitStrategy ?? "load";
32
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
33
+ // Same viewport-profile split as analyzePageStructure (structure.ts):
34
+ // width/height fill the `viewport` key, mobile-profile flags spread in as
35
+ // sibling context options. Desktop resolves to no extra fields, so the
36
+ // default call shape is unchanged.
37
+ const { width, height, ...contextOpts } = resolveViewport(opts?.viewport);
38
+ const page = await browser.newPage({ viewport: { width, height }, ...contextOpts });
33
39
  await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
34
40
  // "load" can fire before late XHRs settle, so give the page a fixed
35
41
  // settle window; networkidle already means the network went quiet.
package/dist/cli.js CHANGED
@@ -23,6 +23,10 @@ const waitStrategySchema = z
23
23
  .enum(["load", "networkidle"])
24
24
  .default("load")
25
25
  .describe("'load' + settle works on heavy sites; 'networkidle' waits for total quiet");
26
+ const viewportSchema = z
27
+ .enum(["desktop", "mobile"])
28
+ .default("desktop")
29
+ .describe("desktop = 1440x900 (default); mobile = 390x844 iPhone-class with deviceScaleFactor 3, isMobile + hasTouch");
26
30
  const cacheRoot = process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp");
27
31
  let cache;
28
32
  try {
@@ -77,11 +81,13 @@ server.tool("list_categories", "List the filter taxonomy available on Awwwards:
77
81
  server.tool("capture_live_site", "Take a fresh full-page screenshot of a live website URL using a headless browser. Requires the optional playwright dependency.", {
78
82
  url: z.string().url().describe("Absolute URL of the site to capture"),
79
83
  waitStrategy: waitStrategySchema,
84
+ viewport: viewportSchema,
80
85
  }, (args) => asMcpResult(handlers.capture_live_site(args)));
81
86
  server.tool("analyze_page_structure", "Extract a page's section band map (tag, label, background color, offset, height per band) via a headless browser. Works on live URLs and file:// paths — use it to compare a reference site's structure against your local build.", {
82
87
  url: z.string().url().describe("Absolute URL (https:// or file://) of the page to analyze"),
83
88
  maxBands: z.number().int().min(5).max(60).default(40).describe("Cap on returned bands"),
84
89
  waitStrategy: waitStrategySchema,
90
+ viewport: viewportSchema,
85
91
  }, (args) => asMcpResult(handlers.analyze_page_structure(args)));
86
92
  server.tool("record_site_motion", "Record a short motion-through video of a live website — preloader, scroll-triggered and hover/cursor animations — and return an inline filmstrip JPEG plus the .webm path. Requires the optional playwright and ffmpeg-static dependencies.", {
87
93
  url: z.string().url().describe("Absolute URL of the site to record"),
@@ -93,6 +99,7 @@ server.tool("record_site_motion", "Record a short motion-through video of a live
93
99
  .default(16)
94
100
  .describe("Filmstrip tile count (default 16 → a 4x4 grid)"),
95
101
  waitStrategy: waitStrategySchema,
102
+ viewport: viewportSchema,
96
103
  }, (args) => asMcpResult(handlers.record_site_motion(args)));
97
104
  // Auto-refresh: if the index is stale (or absent) and no crawl is running,
98
105
  // re-index in the background. Serving is never blocked; errors are stderr-only.
package/dist/motion.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { CAPTURE_INSTALL_HINT } from "./capture.js";
7
+ import { resolveViewport } from "./viewport.js";
7
8
  export const MOTION_FFMPEG_HINT = "Motion recording needs ffmpeg-static, which is an optional dependency.\n" +
8
9
  "Install it with: npm install -D ffmpeg-static\n" +
9
10
  "Then retry record_site_motion.";
@@ -167,9 +168,17 @@ export async function recordSiteMotion(url, opts) {
167
168
  const videoTmp = mkdtempSync(join(opts.cacheImagesDir, ".video-tmp-"));
168
169
  const waitStrategy = opts.waitStrategy ?? "load";
169
170
  try {
171
+ // Viewport profile split (same as structure/capture): width/height fill
172
+ // the `viewport` key; the mobile-profile flags (deviceScaleFactor/
173
+ // isMobile/hasTouch) spread in as sibling context options AFTER the
174
+ // existing fields. recordVideo.size derives from the same profile so the
175
+ // video canvas matches the viewport (desktop keeps the 1440x900 canvas; a
176
+ // mobile recording gets a 390x844 canvas instead of a pillarboxed one).
177
+ const { width, height, ...contextOpts } = resolveViewport(opts.viewport);
170
178
  const context = await browser.newContext({
171
- viewport: { width: 1440, height: 900 },
172
- recordVideo: { dir: videoTmp, size: { width: 1440, height: 900 } },
179
+ viewport: { width, height },
180
+ recordVideo: { dir: videoTmp, size: { width, height } },
181
+ ...contextOpts,
173
182
  });
174
183
  try {
175
184
  const page = await context.newPage();
package/dist/server.js CHANGED
@@ -76,7 +76,12 @@ export function createHandlers(deps) {
76
76
  // filter was applied, so check every client-checkable filter. Color is
77
77
  // never client-checkable (site rows carry no colors); it is handled by
78
78
  // never serving color searches from cache (see search_sites).
79
- function matchesFilters(s, f, honorUrlSource) {
79
+ // skipQueryCheck=true: the rows already matched the free-text query through
80
+ // FTS (prefix+stem semantics); re-checking with substring semantics would
81
+ // wrongly drop stem matches ("magazines" → "Magazine"), so only the
82
+ // non-query filters run. Scraped rows keep the check (default false) — they
83
+ // never came from FTS.
84
+ function matchesFilters(s, f, honorUrlSource, skipQueryCheck = false) {
80
85
  const source = urlSource(f);
81
86
  if (f.tags?.length) {
82
87
  const tagsToCheck = honorUrlSource && source === "tag" ? f.tags.slice(1) : f.tags;
@@ -95,7 +100,7 @@ export function createHandlers(deps) {
95
100
  if (!s.awards.includes(AWARD_FILTER_LABELS[f.award]))
96
101
  return false;
97
102
  }
98
- if (f.query) {
103
+ if (f.query && !skipQueryCheck) {
99
104
  const queryTokens = tokenizeQuery(f.query);
100
105
  if (queryTokens.length) {
101
106
  const hay = (s.title + " " + s.tags.join(" ")).toLowerCase();
@@ -136,18 +141,44 @@ export function createHandlers(deps) {
136
141
  // Color can't be verified client-side (site rows carry no colors), so a
137
142
  // color search always scrapes its filter page; everything else is
138
143
  // client-checkable against the index.
139
- let sites = args.color
140
- ? []
141
- : cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
144
+ let sites;
145
+ let ftsRows = false; // query served from FTS-ranked rows (bm25 order)
146
+ let ftsEmpty = false; // FTS ran and matched nothing → loose hints apply
147
+ if (args.color) {
148
+ sites = [];
149
+ }
150
+ else if (args.query) {
151
+ const ranked = cache.searchSites(args.query, SITE_TTL_MS);
152
+ if (ranked) {
153
+ ftsRows = true;
154
+ ftsEmpty = ranked.length === 0;
155
+ // FTS already applied the query (prefix+stem, bm25-ranked). Re-check
156
+ // only the non-query filters; re-checking the query here with
157
+ // substring semantics would wrongly drop prefix/stem matches.
158
+ // Array.filter preserves the bm25 order.
159
+ sites = ranked.filter((s) => matchesFilters(s, args, false, true));
160
+ }
161
+ else {
162
+ // FTS5 unavailable or no usable tokens: legacy substring path.
163
+ sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
164
+ }
165
+ }
166
+ else {
167
+ sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
168
+ }
142
169
  // A scrape normally REPLACES the matched rows (fresh rows are only
143
170
  // guaranteed the URL filter), so it must run when the cache cannot serve
144
171
  // the requested page window at all. When the window is only PARTIALLY
145
- // filled (e.g. a tokenized query matched 3 cached rows for count 6),
146
- // replacing would discard already-verified matches — those runs scrape
147
- // the filter page and MERGE instead: dedupe by slug, verified cache rows
148
- // win duplicate slugs, scraped-only rows appended, all newest-first like
149
- // getSites; the merge result is client-checked as before (cache rows
150
- // already passed matchesFilters(false), scraped rows matchesFilters(true)).
172
+ // filled (e.g. a query matched 3 cached rows for count 6), replacing
173
+ // would discard already-verified matches — those runs scrape the filter
174
+ // page and MERGE instead: dedupe by slug, verified cache rows win
175
+ // duplicate slugs, scraped-only rows appended; the merge result is
176
+ // client-checked as before (cache rows already passed matchesFilters,
177
+ // scraped rows matchesFilters(true)). Non-FTS runs sort everything
178
+ // newest-first like getSites; FTS runs keep the bm25-ranked cache rows
179
+ // first in rank order and append the scraped-only rows newest-first
180
+ // among themselves — re-sorting ranked rows by createdAt would destroy
181
+ // the ranking the query asked for.
151
182
  const pageWindowEmpty = sites.slice((page - 1) * count, page * count).length === 0;
152
183
  const pageWindowPartial = !pageWindowEmpty && sites.length < page * count;
153
184
  if (pageWindowEmpty) {
@@ -180,14 +211,26 @@ export function createHandlers(deps) {
180
211
  if (parsed.length > 0) {
181
212
  cache.upsertSites(parsed);
182
213
  const fresh = parsed.filter((s) => matchesFilters(s, args, true));
183
- const bySlug = new Map();
184
- // Scraped rows seed the map; verified cache rows then overwrite any
185
- // duplicate slug, so they always win.
186
- for (const s of fresh)
187
- bySlug.set(s.slug, s);
188
- for (const s of sites)
189
- bySlug.set(s.slug, s);
190
- sites = [...bySlug.values()].sort((a, b) => b.createdAt - a.createdAt);
214
+ if (ftsRows) {
215
+ // Ranked cache rows keep their bm25 order; scraped-only rows
216
+ // append newest-first (cache rows win duplicate slugs, as
217
+ // everywhere — a slug in the ranked set is never re-added).
218
+ const rankedSlugs = new Set(sites.map((s) => s.slug));
219
+ const scrapedOnly = fresh
220
+ .filter((s) => !rankedSlugs.has(s.slug))
221
+ .sort((a, b) => b.createdAt - a.createdAt);
222
+ sites = [...sites, ...scrapedOnly];
223
+ }
224
+ else {
225
+ const bySlug = new Map();
226
+ // Scraped rows seed the map; verified cache rows then overwrite any
227
+ // duplicate slug, so they always win.
228
+ for (const s of fresh)
229
+ bySlug.set(s.slug, s);
230
+ for (const s of sites)
231
+ bySlug.set(s.slug, s);
232
+ sites = [...bySlug.values()].sort((a, b) => b.createdAt - a.createdAt);
233
+ }
191
234
  }
192
235
  }
193
236
  catch {
@@ -197,6 +240,17 @@ export function createHandlers(deps) {
197
240
  const ordered = orderByScore(sites, args.sortBy);
198
241
  const slice = ordered.slice((page - 1) * count, page * count);
199
242
  if (slice.length === 0) {
243
+ // Loose-match hint, computed once: only for genuine FTS zero results
244
+ // (FTS ran and matched nothing) — rows dropped by the non-query
245
+ // filters are not "loose matches". Up to 3 OR-relaxed slugs.
246
+ let looseHint = "";
247
+ if (ftsEmpty && args.query) {
248
+ const orSlugs = (cache.searchSites(args.query, SITE_TTL_MS, 3, "OR") ?? [])
249
+ .map((s) => s.slug);
250
+ if (orSlugs.length > 0) {
251
+ looseHint = `\nLoose matches (any token): ${orSlugs.join(", ")}.`;
252
+ }
253
+ }
200
254
  if (sites.length === 0) {
201
255
  // True zero-result search: suggest the closest taxonomy tags. The
202
256
  // taxonomy comes from the cache only — never a live fetch just to
@@ -209,15 +263,17 @@ export function createHandlers(deps) {
209
263
  return {
210
264
  content: [
211
265
  text(`No sites matched the search. Closest filter tags: ${suggestions.join(", ")}. ` +
212
- "Run list_categories for the full taxonomy."),
266
+ `Run list_categories for the full taxonomy.${looseHint}`),
213
267
  ],
214
268
  };
215
269
  }
216
270
  }
217
271
  return {
218
272
  content: [
219
- text("No sites matched the search on this page. Try fewer filters or run list_categories. " +
220
- "(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)"),
273
+ text(`No sites matched the search on this page. Try fewer filters or run list_categories. ` +
274
+ "(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)" +
275
+ // looseHint is empty unless this is a true FTS zero result.
276
+ looseHint),
221
277
  ],
222
278
  };
223
279
  }
@@ -419,7 +475,12 @@ export function createHandlers(deps) {
419
475
  // the injectable playwright loader — opts must land in fourth place.
420
476
  const capture = deps.captureFn ??
421
477
  ((url, imagesDir, opts) => import("./capture.js").then((m) => m.captureLiveSite(url, imagesDir, undefined, opts)));
422
- const result = await capture(args.url, cache.imagesDir, { waitStrategy: args.waitStrategy });
478
+ // The tool schema defaults viewport to "desktop" (zod); the ?? keeps
479
+ // direct handler calls on the same explicit path.
480
+ const result = await capture(args.url, cache.imagesDir, {
481
+ waitStrategy: args.waitStrategy,
482
+ viewport: args.viewport ?? "desktop",
483
+ });
423
484
  if ("error" in result)
424
485
  return { content: [text(result.error)], isError: true };
425
486
  return {
@@ -442,6 +503,7 @@ export function createHandlers(deps) {
442
503
  ((url, maxBands, opts) => import("./structure.js").then((m) => m.analyzePageStructure(url, undefined, maxBands, opts)));
443
504
  const structure = await analyze(args.url, args.maxBands, {
444
505
  waitStrategy: args.waitStrategy,
506
+ viewport: args.viewport ?? "desktop",
445
507
  });
446
508
  if ("error" in structure)
447
509
  return { content: [text(structure.error)], isError: true };
@@ -454,14 +516,15 @@ export function createHandlers(deps) {
454
516
  async function record_site_motion(args) {
455
517
  try {
456
518
  // Lazy default: playwright/ffmpeg are only touched when the tool runs.
457
- // The default forwards motionOpts wholesale, so waitStrategy flows into
458
- // recordSiteMotion's MotionOpts (which already accepts it).
519
+ // The default forwards motionOpts wholesale, so waitStrategy and viewport
520
+ // flow into recordSiteMotion's MotionOpts (which already accepts both).
459
521
  const motion = deps.motionFn ??
460
522
  ((url, motionOpts) => import("./motion.js").then((m) => m.recordSiteMotion(url, motionOpts)));
461
523
  const result = await motion(args.url, {
462
524
  cacheImagesDir: cache.imagesDir,
463
525
  frames: args.frames,
464
526
  waitStrategy: args.waitStrategy,
527
+ viewport: args.viewport ?? "desktop",
465
528
  });
466
529
  if ("error" in result)
467
530
  return { content: [text(result.error)], isError: true };
package/dist/structure.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CAPTURE_INSTALL_HINT } from "./capture.js";
2
+ import { resolveViewport } from "./viewport.js";
2
3
  // Runs IN THE PAGE via page.evaluate. Collects full-width, tall, opaque
3
4
  // elements as band candidates; body is always the base candidate. Gradient
4
5
  // shorthand backgrounds leave backgroundColor transparent — such sections
@@ -182,7 +183,12 @@ export async function analyzePageStructure(url, loader = () => import("playwrigh
182
183
  }
183
184
  try {
184
185
  const waitStrategy = opts?.waitStrategy ?? "load";
185
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
186
+ // Viewport profile split: width/height fill playwright's `viewport` key;
187
+ // the mobile-profile flags (deviceScaleFactor/isMobile/hasTouch) are
188
+ // sibling context options. The desktop profile resolves to no extra
189
+ // fields, so the default call shape is unchanged.
190
+ const { width, height, ...contextOpts } = resolveViewport(opts?.viewport);
191
+ const page = await browser.newPage({ viewport: { width, height }, ...contextOpts });
186
192
  await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
187
193
  // "load" can fire before late XHRs settle, so give the page a fixed
188
194
  // settle window; networkidle already means the network went quiet.
@@ -0,0 +1,7 @@
1
+ export const VIEWPORT_PROFILES = {
2
+ desktop: { width: 1440, height: 900 },
3
+ mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
4
+ };
5
+ export function resolveViewport(name) {
6
+ return VIEWPORT_PROFILES[name ?? "desktop"];
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "awwwards-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Free MCP server giving AI agents design inspiration from Awwwards: search award-winning sites with inline screenshots and extract design DNA.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,9 @@ Run this loop before building anything visual:
31
31
  3. **Search.** Call `search_sites` with 1–3 filters (e.g.
32
32
  `{ color: "#404040", tags: ["3d", "portfolio"] }`). Judge the results from
33
33
  the inline screenshots, not just titles. Shortlist 2–3 candidates.
34
+ Free-text queries are porter-stem + prefix-matched and BM25-ranked
35
+ (`"magazines"` finds Magazine-tagged sites, best matches first); multi-word
36
+ queries keep AND semantics — both tokens must hit the same site.
34
37
  4. **Live reference URL named? Capture it full-page first.** (and later capture
35
38
  your own build the same way — compare both against each other) If the user
36
39
  points at a specific live site (e.g. "recreate cerebrium.ai"), call
@@ -38,7 +41,9 @@ Run this loop before building anything visual:
38
41
  full-page PNG — every section, top to bottom. Cached Awwwards screenshots
39
42
  are hero-only crops (~880×660) and hide everything below the fold: the
40
43
  sections that make a site's structure distinctive (pricing, feature
41
- layouts, contrast breaks, footer) were never visible in them.
44
+ layouts, contrast breaks, footer) were never visible in them. For
45
+ mobile-excellence references pass `viewport: "mobile"` — and capture BOTH
46
+ viewports when the desktop and mobile designs diverge.
42
47
  5. **Get the design DNA.** Call `get_site_details` on the top pick for its
43
48
  palette, technologies, design elements, awards, and description. If it
44
49
  reports a layout-drift error, fall back to judging the shortlisted
@@ -68,8 +73,10 @@ Run this loop before building anything visual:
68
73
  8. **Verify structure, then polish.** After building, capture your own build
69
74
  full-page (`capture_live_site` on its `file://` or served URL) and run
70
75
  `analyze_page_structure` on BOTH the reference and the build. Compare band
71
- maps section by section (count, order, backgrounds, heights). Fix
72
- distribution mismatches first — a section that is 3× the reference's height
76
+ maps section by section (count, order, backgrounds, heights). Match the
77
+ reference's viewport when comparing: for mobile-excellence references pass
78
+ `viewport: "mobile"`, and capture BOTH viewports when the design diverges.
79
+ Fix distribution mismatches first — a section that is 3× the reference's height
73
80
  is a structural bug no amount of pixel polish fixes. Match the reference's
74
81
  band structure, never just its total height.
75
82
 
@@ -68,6 +68,9 @@ Capture rules that prevent re-shoots:
68
68
  - **Capture BEFORE building** — the doctrine step. Review first, code second.
69
69
  - Use a **consistent viewport** (1440×900 matches the QA scripts) so your
70
70
  reference tiles and build tiles are comparable.
71
+ - For phone-class references pass `record_site_motion` a `viewport: "mobile"`
72
+ (390×844 @3x with isMobile + hasTouch) — and capture BOTH viewports when
73
+ the desktop and mobile designs diverge.
71
74
  - **Pre-scroll** to fire lazy content, scroll back to top, then record —
72
75
  otherwise reveal-on-scroll sections record as blank boxes.
73
76
  - For multi-page sites, record **each page** you'll rebuild (home, projects,