awwwards-mcp 1.0.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.
@@ -0,0 +1,208 @@
1
+ import { CAPTURE_INSTALL_HINT } from "./capture.js";
2
+ // Runs IN THE PAGE via page.evaluate. Collects full-width, tall, opaque
3
+ // elements as band candidates; body is always the base candidate. Gradient
4
+ // shorthand backgrounds leave backgroundColor transparent — such sections
5
+ // fall through to the nearest opaque ancestor (usually body).
6
+ //
7
+ // page.evaluate serializes plain data only, so this is a single
8
+ // self-contained function returning { title, totalHeight, candidates }
9
+ // (no closures over module scope). The Node tsconfig has no DOM lib, so
10
+ // browser globals are reached through globalThis.
11
+ //
12
+ // The backgroundColor alpha parsing in the visit loop (including the
13
+ // modern-color-function fallback) is mirrored in test/structure.test.ts
14
+ // ("SCAN_SNIPPET alpha parsing handles space syntax and percentage alphas")
15
+ // and must stay in sync.
16
+ export const SCAN_SNIPPET = () => {
17
+ const g = globalThis;
18
+ const doc = g.document;
19
+ const totalHeight = Math.round(doc.documentElement.scrollHeight);
20
+ const bodyW = doc.body.getBoundingClientRect().width || 1;
21
+ const bodyBg = g.getComputedStyle(doc.body).backgroundColor;
22
+ const candidates = [
23
+ {
24
+ tag: "body",
25
+ label: "body",
26
+ bg: bodyBg,
27
+ top: 0,
28
+ height: totalHeight,
29
+ textStart: "",
30
+ },
31
+ ];
32
+ const seen = new Set();
33
+ const visit = (el, depth) => {
34
+ if (depth > 6 || candidates.length >= 300 || seen.has(el))
35
+ return;
36
+ seen.add(el);
37
+ const r = el.getBoundingClientRect();
38
+ if (el !== doc.body && r.width >= 0.6 * bodyW && r.height >= 120) {
39
+ const s = g.getComputedStyle(el);
40
+ const color = s.backgroundColor;
41
+ // Alpha parse tolerant of legacy comma syntax and CSS Color 4
42
+ // space syntax: rgb(r g b / a), rgb(r, g, b, a), and percentage alphas.
43
+ // A rgba?() miss falls back by color function: modern opaque functions
44
+ // (oklch/oklab/lab/lch/hwb/color) count as opaque (alpha 1); anything
45
+ // else (gradients, keywords) stays conservative at alpha 0.
46
+ const m = /rgba?\(([^)]+)\)/.exec(color);
47
+ let alpha = 0;
48
+ if (m) {
49
+ const parts = m[1].replace(/\//g, " ").trim().split(/[\s,]+/).filter(Boolean);
50
+ const aRaw = parts.length >= 4 ? parts[3] : "1";
51
+ alpha = aRaw.endsWith("%") ? parseFloat(aRaw) / 100 : parseFloat(aRaw);
52
+ if (Number.isNaN(alpha))
53
+ alpha = 0;
54
+ }
55
+ else if (/^(oklch|oklab|lab|lch|hwb|color)\(/.test(color.trim())) {
56
+ alpha = 1;
57
+ }
58
+ if (alpha > 0) {
59
+ candidates.push({
60
+ tag: el.tagName.toLowerCase(),
61
+ label: (el.id ? "#" + el.id : "") + (el.classList.length ? "." + String(el.classList[0]) : ""),
62
+ bg: s.backgroundColor,
63
+ top: Math.round(r.top + g.scrollY),
64
+ height: Math.round(r.height),
65
+ textStart: (el.textContent ?? "").trim().slice(0, 60),
66
+ });
67
+ }
68
+ }
69
+ for (const child of el.children)
70
+ visit(child, depth + 1);
71
+ };
72
+ visit(doc.body, 0);
73
+ return { title: doc.title, totalHeight, candidates };
74
+ };
75
+ // PURE: reduces raw candidates to the page's visible horizontal band map.
76
+ // Sweeps y in 8px steps; at each y the effective background is the covering
77
+ // candidate with the SMALLEST height (most specific wins). Consecutive
78
+ // same-background runs merge (first label wins); bands < 40px are absorbed
79
+ // into the previous band; the count is capped by merging the smallest band
80
+ // into its previous neighbor. Uncovered y (gaps) never emit a band.
81
+ export function collapseBands(cands, totalHeight, maxBands = 40) {
82
+ // Round candidate geometry up front: the sweep samples integer y and the
83
+ // emitted coordinates must be rounded (top 0.4 must yield a band at 0).
84
+ const usable = cands
85
+ .map((c) => ({ ...c, top: Math.round(c.top), height: Math.round(c.height) }))
86
+ .filter((c) => c.height > 0);
87
+ const STEP = 8;
88
+ const bgAt = (y) => {
89
+ let best = null;
90
+ for (const c of usable) {
91
+ if (c.top <= y && y < c.top + c.height) {
92
+ if (!best || c.height < best.height)
93
+ best = c;
94
+ }
95
+ }
96
+ return best;
97
+ };
98
+ const runs = [];
99
+ let y = 0;
100
+ let current = null;
101
+ while (y < totalHeight) {
102
+ const c = bgAt(y);
103
+ if (current && c && c.bg === current.cand.bg) {
104
+ // same run continues
105
+ }
106
+ else {
107
+ if (current)
108
+ runs.push({ cand: current.cand, top: current.top, height: y - current.top });
109
+ current = c ? { cand: c, top: y } : null;
110
+ if (!c) {
111
+ // no candidate covers y (gap): nothing is emitted; when coverage
112
+ // resumes the merge pass below re-joins same-bg neighbors.
113
+ }
114
+ }
115
+ y += STEP;
116
+ }
117
+ if (current)
118
+ runs.push({ cand: current.cand, top: current.top, height: totalHeight - current.top });
119
+ // Merge adjacent same-bg runs (first label wins), absorb tiny slivers.
120
+ // Absorbing a sliver seals the seam so a same-bg run after the sliver
121
+ // stays its own band instead of bleeding across it.
122
+ const merged = [];
123
+ for (const r of runs) {
124
+ const prev = merged[merged.length - 1];
125
+ if (prev && !prev.sealed && prev.cand.bg === r.cand.bg)
126
+ prev.height = r.top + r.height - prev.top;
127
+ else if (r.height < 40 && prev) {
128
+ prev.height = r.top + r.height - prev.top;
129
+ prev.sealed = true;
130
+ }
131
+ else
132
+ merged.push({ ...r });
133
+ }
134
+ // Cap: merge the smallest band into its previous neighbor until <= maxBands.
135
+ while (merged.length > maxBands && merged.length > 1) {
136
+ let idx = 1;
137
+ for (let i = 1; i < merged.length; i++)
138
+ if (merged[i].height < merged[idx].height)
139
+ idx = i;
140
+ merged[idx - 1].height = merged[idx - 1].height + merged[idx].height;
141
+ merged.splice(idx, 1);
142
+ }
143
+ return merged.map((r, i) => ({
144
+ index: i,
145
+ tag: r.cand.tag,
146
+ label: r.cand.label,
147
+ background: r.cand.bg,
148
+ offsetTop: Math.round(r.top),
149
+ height: Math.round(r.height),
150
+ textStart: r.cand.textStart,
151
+ }));
152
+ }
153
+ // Scroll through the page so lazy-rendered sections have layout before a
154
+ // screenshot or band scan (spec requirement; 450px steps, brief settle, back
155
+ // to top). Shared by captureLiveSite and analyzePageStructure — this is the
156
+ // ONE implementation; capture.ts imports it.
157
+ export async function preScroll(page) {
158
+ await page.evaluate(async () => {
159
+ const step = 450;
160
+ for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
161
+ window.scrollTo(0, y);
162
+ await new Promise((r) => setTimeout(r, 40));
163
+ }
164
+ window.scrollTo(0, 0);
165
+ await new Promise((r) => setTimeout(r, 150));
166
+ });
167
+ }
168
+ export async function analyzePageStructure(url, loader = () => import("playwright"), maxBands, opts) {
169
+ let chromium;
170
+ try {
171
+ ({ chromium } = await loader());
172
+ }
173
+ catch {
174
+ return { error: CAPTURE_INSTALL_HINT };
175
+ }
176
+ let browser;
177
+ try {
178
+ browser = await chromium.launch();
179
+ }
180
+ catch {
181
+ return { error: CAPTURE_INSTALL_HINT };
182
+ }
183
+ try {
184
+ const waitStrategy = opts?.waitStrategy ?? "load";
185
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
186
+ await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
187
+ // "load" can fire before late XHRs settle, so give the page a fixed
188
+ // settle window; networkidle already means the network went quiet.
189
+ if (waitStrategy === "load")
190
+ await page.waitForTimeout(3000);
191
+ await preScroll(page);
192
+ const raw = (await page.evaluate(SCAN_SNIPPET));
193
+ return {
194
+ url,
195
+ title: raw.title,
196
+ totalHeight: raw.totalHeight,
197
+ bands: collapseBands(raw.candidates, raw.totalHeight, maxBands ?? 40),
198
+ };
199
+ }
200
+ finally {
201
+ try {
202
+ await browser.close();
203
+ }
204
+ catch {
205
+ /* keep the primary error */
206
+ }
207
+ }
208
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "awwwards-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Free MCP server giving AI agents design inspiration from Awwwards: search award-winning sites with inline screenshots and extract design DNA.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "awwwards-mcp": "dist/cli.js",
9
+ "awwwards-index": "dist/index-cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "skills"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.13.0"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "index": "node dist/index-cli.js",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run",
24
+ "smoke": "tsx test/live-smoke.ts",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.12.0",
29
+ "zod": "^3.24.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^24.0.0",
33
+ "ffmpeg-static": "^5.3.0",
34
+ "playwright": "^1.63.0",
35
+ "tsx": "^4.19.0",
36
+ "typescript": "^5.6.0",
37
+ "vitest": "^3.0.0"
38
+ }
39
+ }
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: awwwards-inspiration
3
+ description: Use when building a site that needs design references (e.g. "make it feel premium", "dark 3D portfolio vibes"), when the user asks for standalone inspiration research ("show me award-winning e-commerce sites", "what's trending in brutalism"), or when setting up or maintaining the awwwards-mcp local index or live-capture tooling (awwwards-index, capture_live_site).
4
+ ---
5
+
6
+ # Awwwards Inspiration
7
+
8
+ You have access to the `awwwards` MCP server: a searchable library of
9
+ award-winning websites with inline screenshots and per-site design DNA. Use
10
+ it to ground design decisions in real, proven references instead of guessing.
11
+
12
+ ## When to use this skill
13
+
14
+ 1. **During site builds** — before writing any UI code, gather references and
15
+ state a design direction.
16
+ 2. **Standalone research** — the user wants inspiration, trends, or examples
17
+ ("show me dark 3D portfolio sites").
18
+ 3. **Index & capture ops** — building the local search index, or taking fresh
19
+ screenshots of live URLs.
20
+
21
+ ## The inspiration loop
22
+
23
+ Run this loop before building anything visual:
24
+
25
+ 1. **Restate the goal as concrete attributes.** Turn the user's request into
26
+ mood, color, technology, and industry terms. "Make it feel premium" becomes
27
+ e.g. "dark, elegant, WebGL, agency portfolio".
28
+ 2. **Ground your vocabulary.** If unsure which filters exist, call
29
+ `list_categories` first — it returns every color hex and tag/technology
30
+ slug you can search by.
31
+ 3. **Search.** Call `search_sites` with 1–3 filters (e.g.
32
+ `{ color: "#404040", tags: ["3d", "portfolio"] }`). Judge the results from
33
+ the inline screenshots, not just titles. Shortlist 2–3 candidates.
34
+ 4. **Live reference URL named? Capture it full-page first.** (and later capture
35
+ your own build the same way — compare both against each other) If the user
36
+ points at a specific live site (e.g. "recreate cerebrium.ai"), call
37
+ `capture_live_site` on that URL before anything else and design from the
38
+ full-page PNG — every section, top to bottom. Cached Awwwards screenshots
39
+ are hero-only crops (~880×660) and hide everything below the fold: the
40
+ sections that make a site's structure distinctive (pricing, feature
41
+ layouts, contrast breaks, footer) were never visible in them.
42
+ 5. **Get the design DNA.** Call `get_site_details` on the top pick for its
43
+ palette, technologies, design elements, awards, and description. If it
44
+ reports a layout-drift error, fall back to judging the shortlisted
45
+ screenshots, `get_site_elements` (which uses a different parser), or a
46
+ `capture_live_site` of the site's URL for a first-hand full-page view.
47
+ 6. **Get component-level visuals (when building).** Call `get_site_elements`
48
+ on shortlisted sites to see individual design elements — 3D models, video
49
+ content, mobile layouts, microcopy — with poster images inline and video
50
+ URLs. Treat element posters as texture, not structure: they are video
51
+ frames (often mid-animation or near-black), not full-section layouts.
52
+ 7. **State the design direction before writing code.** In prose: palette
53
+ (hexes from the references), type mood, layout patterns, and tech choices,
54
+ each traceable to a reference. Then build.
55
+ 8. **Verify structure, then polish.** After building, capture your own build
56
+ full-page (`capture_live_site` on its `file://` or served URL) and run
57
+ `analyze_page_structure` on BOTH the reference and the build. Compare band
58
+ maps section by section (count, order, backgrounds, heights). Fix
59
+ distribution mismatches first — a section that is 3× the reference's height
60
+ is a structural bug no amount of pixel polish fixes. Match the reference's
61
+ band structure, never just its total height.
62
+
63
+ ### Anti-patterns
64
+
65
+ - **Vague single-word searches** ("modern", "nice") — use concrete color/tag/
66
+ technology/award filters instead.
67
+ - **Skipping to code** without stating a direction — the references are
68
+ worthless if nothing is derived from them.
69
+ - **Dumping raw tool output at the user** — curate: show the shortlist, the
70
+ chosen direction, and why.
71
+ - **Designing from a thumbnail or element poster** — thumbnails are hero-only
72
+ crops and posters are video frames; neither shows the page's real
73
+ structure. When the reference URL is known, capture it full-page (step 4).
74
+ - **Padding empty bands to match total height** — if your build's total height
75
+ matches the reference but a spacer/background band is far taller than the
76
+ reference's equivalent, the height was stolen from real content sections.
77
+ Compare band maps, not totals. Spacer and decorative elements are measured
78
+ against the reference's equivalent band — never invented to absorb height.
79
+
80
+ ## Tool reference
81
+
82
+ | Tool | Key params | Returns | Gotchas |
83
+ |------|-----------|---------|---------|
84
+ | `search_sites` | `query` (free text vs titles/tags), `color` (hex like `#404040`), `tags` (array of slugs), `technology` (slug), `award` (`sotd`\|`developer`\|`honorable`), `count` (1–12, default 6), `page` (default 1) | Text list of site cards (title, slug, live URL, awards, tags) + inline JPEG screenshots | Awwwards applies only one URL filter — priority color > award > technology > first tag; the rest are checked client-side. Color searches always scrape live (never cached). Deep pagination is unavailable by design (robots.txt). On live-request failure, stale cache is served when present. |
85
+ | `get_site_details` | `slug` (from `search_sites`, e.g. `l-i-s-a`) | Title, live URL, awards, color palette, technologies, design elements, description, full-size screenshot URL; inline screenshot when available | Cached 7 days; a parse that comes back all-empty is an error, not a quiet empty result. |
86
+ | `get_site_elements` | `slug` | Numbered element list (image or video, with video URLs) + up to 8 inline poster JPEGs | Videos are mp4 URLs (posters only are shown inline). Elements feed from the same fetch as `get_site_details`. |
87
+ | `list_categories` | none | JSON: every color hex and filter/tag slug, plus usage guidance | Cached 30 days. Call this whenever filter vocabulary is uncertain. |
88
+ | `capture_live_site` | `url` (absolute URL) | Full-page PNG saved to disk + inline image | Requires the optional playwright dependency (`npm install -g playwright && npx playwright install chromium`). |
89
+ | `analyze_page_structure` | `url` (absolute URL or `file://` path), `maxBands` (cap on returned bands, default 40) | JSON: `title`, `totalHeight`, and an ordered band map (`index`, `tag`, `label`, `background`, `offsetTop`, `height`, `textStart` (first ~60 chars of the band's text) per band) | Requires playwright. Run it on BOTH the reference and your build (step 8) and compare band maps — count, order, backgrounds, heights — never just total height. |
90
+ | `record_site_motion` | `url` (absolute URL), `frames` (filmstrip tile count, 4–36, default 16 → a 4x4 grid) | Inline filmstrip JPEG of a motion-through pass (preloader dwell, slow scroll, hover/cursor interactions) + the saved .webm path as text | Requires playwright + ffmpeg-static. Runs a ~30 s scripted pass — heavier than a capture, use when motion matters (step on from static captures). |
91
+
92
+ All image results arrive as MCP image content blocks — look at them, don't
93
+ just read the text blocks.
94
+
95
+ ## Index & capture ops
96
+
97
+ **Local index.** `search_sites` works out of the box but unindexed depth is
98
+ limited by polite live scraping (~31 sites per filter page). Build the index
99
+ once for searches across thousands of sites:
100
+
101
+ ```bash
102
+ npx -y -p awwwards-mcp awwwards-index # from the published package
103
+ npm run index # from a repo checkout
104
+ ```
105
+
106
+ - Crawls all ~200 tag pages at 1 request/second (~4 minutes) into a SQLite
107
+ cache at `~/.awwwards-mcp/`.
108
+ - Resumable: interrupt and re-run; completed pages are skipped.
109
+ - The MCP server re-indexes automatically in the background whenever the
110
+ index is stale — you rarely need to run this by hand.
111
+
112
+ **Live captures.** `capture_live_site` needs playwright installed once (see
113
+ table above). Two uses: (1) the user wants a screenshot of a URL that is not
114
+ an Awwwards site, or a fresher view than the cached thumbnails; and (2) —
115
+ the more important one — the user names a live site as the design reference
116
+ for a build: capture it full-page first and derive the structure from that
117
+ image (see step 4 of the loop).
118
+
119
+ **Motion capture.** Static images can't show preloaders, scroll-driven
120
+ animation, or transitions — and most award-winning sites are built around
121
+ exactly those. When the reference site has motion, record it: launch
122
+ Playwright with `recordVideo`, wait out the preloader (~7 s), scroll slowly
123
+ to the bottom in small steps so every scroll-triggered animation fires on
124
+ camera, then extract a filmstrip of frames with ffmpeg (`npm i -D
125
+ ffmpeg-static`, one frame every ~3 s) and Read the frames — or call
126
+ `record_site_motion` directly: the filmstrip comes back inline and the .webm
127
+ path as text.
128
+
129
+ **Filmstrip is the fallback, not the default.** First try Reading the
130
+ video file directly — if your model supports video input, watching the
131
+ scroll-through gives you timing, easing, and transitions the frames can't.
132
+ If the Read comes back with media omitted / "model does not support video
133
+ input", extract the filmstrip and Read the frames instead. A ready-made
134
+ recorder ships in this repo at
135
+ `scripts/record-scrollthrough.mjs` (run it from the repo root; playwright
136
+ and ffmpeg-static are devDependencies).