Website Theme Porter
Ports the visual identity of any live website into a React plus Tailwind CSS project. Extracts colours, typography, spacing, component styles via browser automation, source inspection, curl, wget, DevTools. Writes structured documentation, applies findings as Tailwind v4 CSS tokens, then verifies by visually diffing the original site against the local build. Use when cloning a brand, replicating a design system, matching a reference site, migrating visual identity, copying a style guide, porting a theme from any live URL into a React codebase, matching a client reference design.
npx skills add github:pantheon-org/tekhne --skill development--front-end--website-theme-porterPort the visual identity of any live website into a React + Tailwind CSS project. The workflow is four linear stages: Extract → Document → Apply → Verify.
Principles
- Document before you code: write all theme documentation under the artifacts directory (see Artifact Storage Convention) before touching a single source file.
- Tokens over literals: every extracted value becomes a semantic CSS token; hardcoded colours never appear in component classes.
- Method hierarchy: prefer automated extraction (agent-browser) over manual inspection; document whichever method was used.
- Verify visually: the workflow is not complete until a diff screenshot confirms the local build matches the reference site.
- Respect scope: only port colours, typography, spacing, and component patterns; never copy the source site’s actual stylesheet files.
When to Use
- Replicating a visual design from a live URL into a React + Tailwind codebase
- Running a brand migration or matching a client reference design
- Porting a design system or replicating a style guide into Tailwind v4 tokens
- Verifying a local build against a reference site with a visual diff
Artifact Storage Convention
All artifacts (screenshots, extracted JSON, theme docs) must be saved under:
.context/artifacts/<website-slug>/<YYYY-MM-DD>/
Where <website-slug> is a lowercase, hyphenated version of the target domain
(e.g. designandbloom-co-uk), and <YYYY-MM-DD> is the extraction date.
SLUG="designandbloom-co-uk"
DATE=$(date +%Y-%m-%d)
ARTIFACTS=".context/artifacts/${SLUG}/${DATE}"
mkdir -p "${ARTIFACTS}"
All screenshot paths, JSON dumps, and theme docs in the steps below use ${ARTIFACTS}/
as their base. Never save artifacts to docs/ or any other location.
Reference Loading Schedule
Load each reference file only when its stage begins — do not pre-load all three at once.
| Stage | Load | Do NOT load |
|---|---|---|
| Stage 1: Extract | references/extraction.md |
tailwind-mapping.md, verification.md |
| Stage 2: Document | references/tailwind-mapping.md |
extraction.md (done), verification.md |
| Stage 3: Apply | references/tailwind-mapping.md (if not cached) |
extraction.md, verification.md |
| Stage 4: Verify | references/verification.md |
extraction.md, tailwind-mapping.md |
Stage 1: Extract
Pull raw design tokens from the target website. Choose the method that fits your situation:
Capture method decision tree
Can you run agent-browser OR mcp-playwright in this environment?
YES → use Method A (automated browser extraction) — most thorough
agent-browser: run CLI commands directly
mcp-playwright: use playwright_browser_* MCP tools
NO ↓
Is the site's HTML/CSS source accessible (View Source, curl, wget)?
YES → use Method B (source inspection) — good for SSR/static sites
NO ↓
Can you manually inspect the live site in browser DevTools?
YES → use Method C (manual inspection) — always possible, least automated
NO → unblock agent-browser or mcp-playwright access before continuing
For the full command equivalents table, see references/extraction.md.
Method A — Automated browser extraction (agent-browser or mcp-playwright)
Steps below use agent-browser syntax. If using mcp-playwright, translate each command using the equivalents table above. The JS payloads are identical — only the invocation differs.
1a. Navigate and screenshot
agent-browser open <TARGET_URL> && agent-browser wait --load networkidle
agent-browser screenshot --full "${ARTIFACTS}/source-full.png"
agent-browser set viewport 375 812 && agent-browser screenshot --full "${ARTIFACTS}/source-mobile.png"
agent-browser set viewport 1280 720
1b. Extract computed styles via JS
agent-browser eval --stdin <<'JS'
JSON.stringify({
colors: (() => {
const s = getComputedStyle(document.documentElement);
const props = Array.from(document.styleSheets)
.flatMap(sheet => { try { return Array.from(sheet.cssRules); } catch { return []; } })
.filter(r => r.selectorText === ':root')
.flatMap(r => Array.from(r.style));
return props.filter(p => p.startsWith('--')).reduce((acc, p) => {
acc[p] = s.getPropertyValue(p).trim(); return acc;
}, {});
})(),
computed: (() => {
const el = document.body;
const s = getComputedStyle(el);
return {
fontFamily: s.fontFamily,
fontSize: s.fontSize,
lineHeight: s.lineHeight,
backgroundColor: s.backgroundColor,
color: s.color,
};
})()
}, null, 2)
JS
Save JSON output to ${ARTIFACTS}/tokens-css-vars.json.
If the colors object is empty (site uses no CSS custom properties):
The site styles everything with hardcoded values. Switch to computed-only extraction:
agent-browser eval --stdin <<'JS'
const els = { body: document.body, h1: document.querySelector('h1'),
btn: document.querySelector('button,[class*=btn]'),
nav: document.querySelector('nav,header'),
card: document.querySelector('[class*=card],article') };
const out = {};
for (const [name, el] of Object.entries(els)) {
if (!el) continue;
const s = getComputedStyle(el);
out[name] = { bg: s.backgroundColor, color: s.color, font: s.fontFamily,
fontSize: s.fontSize, fontWeight: s.fontWeight, borderRadius: s.borderRadius,
border: s.border, boxShadow: s.boxShadow, padding: `${s.paddingTop} ${s.paddingRight}` };
}
JSON.stringify(out, null, 2)
JS
Save to ${ARTIFACTS}/tokens-computed.json. Manually assign tokens from these values in Stage 2.
Note in ${ARTIFACTS}/theme/overview.md: “source uses hardcoded values — no design token system detected”.
If the target is a SPA (React, Next.js, Vue, etc.): Content may not be rendered on first paint. Add a scroll-and-wait step before extracting:
agent-browser eval 'window.scrollTo(0, document.body.scrollHeight)'
agent-browser wait --load networkidle
agent-browser wait --timeout 2000
1c. Collect typography, spacing, and key element styles
agent-browser eval --stdin <<'JS'
JSON.stringify(
['h1','h2','h3','h4','p','a','button'].reduce((acc, tag) => {
const el = document.querySelector(tag);
if (!el) return acc;
const s = getComputedStyle(el);
acc[tag] = {
fontFamily: s.fontFamily,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
color: s.color,
letterSpacing: s.letterSpacing,
};
return acc;
}, {}),
null, 2)
JS
Save JSON output to ${ARTIFACTS}/tokens-typography.json.
1d. Capture screenshots of key sections
agent-browser screenshot --full "${ARTIFACTS}/source-annotated.png"
See references/extraction.md for the full JS extraction toolkit, colour parsing
helpers, nav/card/footer extractors, and page-by-page capture patterns.
Method B — Source inspection (curl / wget / View Source)
Use when agent-browser is unavailable or blocked. Fetch the raw HTML and linked
stylesheets, then grep for CSS custom properties and font declarations manually.
curl -sL <TARGET_URL> -o "${ARTIFACTS}/source.html"
grep -oP '(?<=href=")[^"]*\.css[^"]*' "${ARTIFACTS}/source.html"
curl -sL <CSS_URL> -o "${ARTIFACTS}/styles-main.css"
grep -oP '--[\w-]+\s*:\s*[^;]+' "${ARTIFACTS}/styles-main.css" > "${ARTIFACTS}/tokens-raw.txt"
grep -E '@font-face|fonts\.googleapis' "${ARTIFACTS}/styles-main.css" > "${ARTIFACTS}/fonts-raw.txt"
Parse ${ARTIFACTS}/tokens-raw.txt manually and map values to the token table in Stage 2.
Note in ${ARTIFACTS}/theme/overview.md: “extraction method: source inspection (curl)”.
Method C — Manual DevTools inspection
Use as a fallback or to supplement other methods.
- Open the target site in Chrome/Firefox DevTools
- Elements → select
<html>→ Computed tab → filter for--→ copy all custom properties to${ARTIFACTS}/tokens-manual.txt - Network tab → filter
.css→ copy stylesheet contents - Console: run JS snippets from
references/extraction.md→ paste output to${ARTIFACTS}/tokens-computed.json - Take screenshots of key sections (hero, nav, footer, mobile) and save under
${ARTIFACTS}/
Note in ${ARTIFACTS}/theme/overview.md: “extraction method: manual DevTools inspection”.
Stage 2: Document
Write the findings into ${ARTIFACTS}/theme/ before touching any code.
mkdir -p "${ARTIFACTS}/theme"
Required output files
| File | Contents |
|---|---|
${ARTIFACTS}/theme/colours.md |
All extracted colours with hex/HSL, semantic role, usage context |
${ARTIFACTS}/theme/typography.md |
Font families, size scale, weight scale, line heights |
${ARTIFACTS}/theme/spacing.md |
Padding/margin patterns, gap values, container widths |
${ARTIFACTS}/theme/components.md |
Button, card, nav, footer styles — border-radius, shadows, borders |
${ARTIFACTS}/theme/overview.md |
Summary, source URL, extraction date, method used, key decisions |
Colour documentation format
## Colours
| Token | Hex | HSL | Role |
|-------|-----|-----|------|
| --primary | #0D9488 | 174 90% 30% | CTA buttons, links |
| --background | #FFFFFF | 0 0% 100% | Page background |
| --foreground | #111827 | 221 39% 11% | Body text |
Typography documentation format
## Typography
**Primary font**: Inter, sans-serif
**Heading font**: same (or specify if different)
| Element | Size | Weight | Line Height |
|---------|------|--------|-------------|
| h1 | 2.25rem | 700 | 1.2 |
| h2 | 1.875rem | 600 | 1.3 |
| body | 1rem | 400 | 1.6 |
Use the Token Decision Tree in references/tailwind-mapping.md to assign ambiguous colours.
Stage 3: Apply
Translate the documented theme into Tailwind CSS variables and component styles.
3a. Update src/index.css (Tailwind v4 pattern)
@import "tailwindcss";
/* Semantic colour tokens */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(221 39% 11%);
--primary: hsl(174 90% 30%);
--primary-foreground: hsl(0 0% 100%);
--muted: hsl(210 40% 96%);
--muted-foreground: hsl(215 16% 47%);
--border: hsl(214 32% 91%);
--radius: 0.5rem;
}
/* Map to Tailwind utilities */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
}
3b. Add font to index.html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
3c. Update component classes
Replace hardcoded colours/fonts with semantic Tailwind tokens:
// Before
<button className="bg-[#0D9488] text-white px-4 py-2 rounded">
// After
<button className="bg-primary text-primary-foreground px-4 py-2 rounded-[var(--radius)]">
Work page-by-page, starting with shared layout components (Header, Footer) then pages.
Stage 4: Verify
Compare the original site visually against the local build.
4a. Start local dev server
bun run dev # or: npm run dev / vite
4b. Visual diff
agent-browser produces a pixel-diff image and mismatch percentage:
agent-browser diff url <TARGET_URL> http://localhost:5173 --screenshot "${ARTIFACTS}/diff-homepage.png"
mcp-playwright — navigate to each URL and take screenshots without a filename; images are returned inline into context so the agent can inspect and describe differences directly:
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # source — inline
playwright_browser_navigate(url: "http://localhost:5173")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # local — inline
4c. Deployed verification (if Vercel / other hosting)
# agent-browser
agent-browser diff url <TARGET_URL> https://<your-preview>.vercel.app --screenshot "${ARTIFACTS}/diff-deployed.png"
# mcp-playwright: repeat the navigate + take_screenshot (no filename) pattern above against the preview URL
See references/verification.md for the full diff interpretation guide, threshold
table, and symptom→fix patterns for all common mismatches.
When Not to Use
- Login-walled or paywalled sites — extraction requires an authenticated session; the browser cannot reach the styled content without credentials.
- Canvas-rendered or WebGL UIs — apps that paint entirely to a
<canvas>element have no CSS to extract;getComputedStylereturns nothing useful. - Sites with heavily obfuscated class names (CSS Modules / CSS-in-JS hashes) — class names like
_3xKy9are generated at build time and change on every deploy; do not attempt to port them. Extract computed values only via Method A JS snippets. - Designs you do not have permission to replicate — confirm you have the right to port the visual identity before starting.
What to Extract
Prioritise in this order:
- Colour palette — backgrounds, text, borders, CTAs, accents
- Typography — font families (check
@font-faceor Google Fonts links), size scale, weights - Spacing rhythm — common padding/margin values (e.g. 16px, 24px, 48px grid)
- Border radius — buttons, cards, inputs (flat vs rounded)
- Shadows — none, subtle, card, elevated
- Component patterns — button variants, card borders, nav height, footer layout
Anti-Patterns
NEVER wrap HSL values in hsl() inside @theme inline
WHY: In Tailwind v4, --color-primary: hsl(174 90% 31%) inside @theme inline double-wraps when Tailwind generates utilities, so bg-primary resolves to hsl(hsl(174 90% 31%)), an invalid value; the colour renders as transparent or black.
BAD:
@theme inline {
--color-primary: hsl(174 90% 31%);
}
GOOD:
:root { --primary: hsl(174 90% 31%); }
@theme inline { --color-primary: var(--primary); }
NEVER extract colours from :hover pseudo-states
WHY: getComputedStyle on a hovered element requires the pointer to be physically over it. The value may silently return the resting state, or the hover shade gets assigned as the base token so all buttons show the hover shade permanently.
BAD: Record the colour seen while hovering as the button’s base token.
GOOD: Extract the resting-state colour and document hover shades separately.
NEVER use prefers-color-scheme media query colours as your base tokens
WHY: Computed styles reflect the current OS colour scheme at extraction time. If the machine is in dark mode, the extracted background is near-black, and applying it to a light-mode project inverts the whole scheme.
BAD: Extract with the OS in dark mode and record the result as the base palette.
GOOD: Extract in light mode, or document which mode was active in overview.md.
NEVER use /tmp for any artifact storage
WHY: /tmp is ephemeral and session-scoped, so artifacts saved there are lost when the session ends.
BAD:
agent-browser screenshot /tmp/source.png
GOOD:
agent-browser screenshot "${ARTIFACTS}/source-full.png"
NEVER save artifacts to docs/ or any other location
WHY: The storage convention keeps every site and extraction date separate and findable under one root.
BAD: Writing theme docs to docs/theme.md.
GOOD: Writing them under ${ARTIFACTS}/theme/.
NEVER copy the source site’s actual CSS files or stylesheets into your project
WHY: Copying stylesheets ports someone else’s code rather than their visual identity, and drags in selectors and rules you do not own.
BAD: curl -sL <CSS_URL> -o src/vendor.css and import it.
GOOD: Rebuild every value from scratch using the extracted numbers.
NEVER use arbitrary Tailwind values (bg-[#abc]) for semantic colours
WHY: Arbitrary values bypass the token system, so there is no single source of truth for the colour.
BAD:
<button className="bg-[#0D9488] text-white">
GOOD:
<button className="bg-primary text-primary-foreground">
NEVER write source code before the theme documentation exists
WHY: Documenting first forces every value to be extracted and named before it is used, which is what stops ad hoc literals leaking into components.
BAD: Editing src/index.css straight after the first screenshot.
GOOD: Write colours.md, typography.md, spacing.md, components.md and overview.md first, then apply.
NEVER pre-load all three reference files at once
WHY: Each reference belongs to one stage; loading everything up front spends context on material the current stage does not need.
BAD: Read extraction.md, tailwind-mapping.md and verification.md before Stage 1.
GOOD: Load references/extraction.md for Stage 1, references/tailwind-mapping.md for Stages 2 and 3, references/verification.md for Stage 4.
NEVER port obfuscated class names
WHY: Names like _3xKy9 are generated at build time and change on every deploy.
BAD: Copy ._3xKy9 rules into the project.
GOOD: Extract computed values only, via the Method A JS snippets.
NEVER declare the port complete without a visual diff
WHY: Only a diff screenshot confirms the local build matches the reference site.
BAD: Finishing after Stage 3 because the code compiles.
GOOD: Run Stage 4 and record the diff result in ${ARTIFACTS}/.
References
| Topic | Reference | When to Use |
|---|---|---|
| Extraction toolkit | references/extraction.md | CRITICAL: load at the start of Stage 1 for the full JS toolkit, colour parsing helpers and page capture patterns |
| Tailwind mapping | references/tailwind-mapping.md | CRITICAL: load at the start of Stage 2 for token tables, HSL converter, Token Decision Tree and the stylesheet template |
| Verification | references/verification.md | HIGH: load at the start of Stage 4 for diff thresholds and the symptom-to-fix table |
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
No eval scenarios available.
CSS Extraction Toolkit
Reusable JavaScript snippets to extract design tokens from a live page.
Run via agent-browser eval --stdin or playwright_browser_evaluate(function: '() => { … }') — the JS payloads are identical; only the invocation differs.
mcp-playwright advantage: playwright_browser_take_screenshot() (no filename) returns the image inline in the agent’s context for immediate visual inspection — no file I/O needed.
agent-browser vs mcp-playwright Command Equivalents
| agent-browser | mcp-playwright |
|---|---|
agent-browser open <URL> |
playwright_browser_navigate(url) |
agent-browser wait --load networkidle |
playwright_browser_wait_for(time: 2) |
agent-browser screenshot --full <path> |
playwright_browser_take_screenshot(filename, fullPage: true) |
agent-browser set viewport 375 812 |
playwright_browser_resize(width: 375, height: 812) |
agent-browser eval '<JS>' |
playwright_browser_evaluate(function: '() => { <JS> }') |
agent-browser eval --stdin <<'JS' … JS |
playwright_browser_evaluate(function: '() => { … }') |
agent-browser --session source open <URL> |
open a new tab: playwright_browser_tabs(action: "new") then playwright_browser_navigate(url) |
agent-browser diff url <A> <B> |
screenshot both URLs without filename — images are returned inline for direct visual comparison |
Omit filename on playwright_browser_take_screenshot during verification to leverage inline image context.
Full Token Dump
Extracts all CSS custom properties from :root across all loaded stylesheets:
agent-browser eval --stdin <<'JS'
JSON.stringify((() => {
const style = getComputedStyle(document.documentElement);
const props = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.selectorText === ':root') {
for (const prop of rule.style) {
props.push([prop, style.getPropertyValue(prop).trim()]);
}
}
}
} catch (_) { /* cross-origin sheet */ }
}
return Object.fromEntries(props);
})(), null, 2)
JS
Colour Extraction
All background and text colours across the page
agent-browser eval --stdin <<'JS'
JSON.stringify((() => {
const seen = new Set();
const result = [];
for (const el of document.querySelectorAll('*')) {
const s = getComputedStyle(el);
for (const prop of ['color', 'backgroundColor', 'borderColor']) {
const val = s[prop];
if (val && val !== 'rgba(0, 0, 0, 0)' && val !== 'transparent' && !seen.has(val)) {
seen.add(val);
result.push({ property: prop, value: val, tag: el.tagName.toLowerCase() });
}
}
}
return result.slice(0, 40); // top 40 unique colours
})(), null, 2)
JS
Convert rgb() to hex (run in browser context)
agent-browser eval --stdin <<'JS'
function rgbToHex(rgb) {
const m = rgb.match(/\d+/g);
if (!m || m.length < 3) return rgb;
return '#' + m.slice(0,3).map(n => parseInt(n).toString(16).padStart(2,'0')).join('');
}
// Example usage:
rgbToHex('rgb(13, 148, 136)') // '#0d9488'
JS
Typography Extraction
Full font stack for key elements
agent-browser eval --stdin <<'JS'
JSON.stringify(
['h1','h2','h3','h4','h5','p','li','a','button','input','label','span']
.reduce((acc, tag) => {
const el = document.querySelector(tag);
if (!el) return acc;
const s = getComputedStyle(el);
acc[tag] = {
fontFamily: s.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
letterSpacing: s.letterSpacing,
textTransform: s.textTransform,
color: s.color,
};
return acc;
}, {}),
null, 2)
JS
Detect Google Fonts or font-face declarations
agent-browser eval --stdin <<'JS'
const fonts = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.FONT_FACE_RULE) {
fonts.push({
family: rule.style.getPropertyValue('font-family'),
src: rule.style.getPropertyValue('src').split(',')[0].substring(0, 100),
});
}
}
} catch (_) {}
}
// Also check link tags
const links = Array.from(document.querySelectorAll('link[href*="fonts.googleapis"]'))
.map(l => l.href);
JSON.stringify({ fontFace: fonts, googleFonts: links }, null, 2)
JS
Spacing Extraction
Common padding/margin values used across layout elements
agent-browser eval --stdin <<'JS'
JSON.stringify((() => {
const freq = {};
for (const el of document.querySelectorAll('section, div, article, header, footer, nav, main')) {
const s = getComputedStyle(el);
for (const prop of ['paddingTop','paddingBottom','paddingLeft','paddingRight','marginTop','marginBottom']) {
const v = s[prop];
if (v && v !== '0px') freq[v] = (freq[v] || 0) + 1;
}
}
return Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0,20).map(([v,n]) => `${v} (×${n})`);
})(), null, 2)
JS
Component Style Extraction
Button variants
agent-browser eval --stdin <<'JS'
const selectors = ['button','[class*="btn"]','[class*="button"]','a[class*="cta"]'];
const results = [];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of Array.from(els).slice(0, 3)) {
const s = getComputedStyle(el);
results.push({
selector: sel,
text: el.textContent?.trim().slice(0, 30),
bg: s.backgroundColor,
color: s.color,
border: s.border,
borderRadius: s.borderRadius,
padding: `${s.paddingTop} ${s.paddingRight} ${s.paddingBottom} ${s.paddingLeft}`,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
boxShadow: s.boxShadow,
});
}
}
JSON.stringify(results, null, 2)
JS
Navigation bar
agent-browser eval --stdin <<'JS'
const nav = document.querySelector('nav, header, [class*="nav"], [class*="header"]');
if (!nav) 'no nav found';
else {
const s = getComputedStyle(nav);
JSON.stringify({
height: nav.getBoundingClientRect().height,
backgroundColor: s.backgroundColor,
borderBottom: s.borderBottom,
boxShadow: s.boxShadow,
position: s.position,
padding: `${s.paddingTop} ${s.paddingRight}`,
fontFamily: s.fontFamily.split(',')[0].replace(/['"]/g,'').trim(),
fontSize: s.fontSize,
fontWeight: s.fontWeight,
color: s.color,
}, null, 2);
}
JS
Card / content block
agent-browser eval --stdin <<'JS'
const card = document.querySelector('[class*="card"], article, .post, [class*="tile"]');
if (!card) 'no card found';
else {
const s = getComputedStyle(card);
JSON.stringify({
backgroundColor: s.backgroundColor,
border: s.border,
borderRadius: s.borderRadius,
boxShadow: s.boxShadow,
padding: `${s.paddingTop} ${s.paddingRight}`,
}, null, 2);
}
JS
Page-by-Page Capture
For multi-page sites, snapshot each route.
agent-browser:
# ARTIFACTS must be set before running (see SKILL.md Artifact Storage Convention)
PAGES=("" "/about" "/services" "/pricing" "/contact")
for PAGE in "${PAGES[@]}"; do
agent-browser open "${TARGET_URL}${PAGE}" && \
agent-browser wait --load networkidle && \
agent-browser screenshot --full "${ARTIFACTS}/source${PAGE//\//-}.png"
done
mcp-playwright (screenshots returned inline — no filename needed for visual inspection):
# Repeat for each page
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # visible inline in context
playwright_browser_navigate(url: "<TARGET_URL>/about")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true)
Provide filename only if you need to persist screenshots for later comparison.
Tips
- Run extractions on the fully loaded page — wait for
networkidlebefore eval - If CSS vars return empty, the site may use inline styles or a CSS-in-JS library — use the computed style approach instead
- Cross-origin stylesheets will throw on
cssRulesaccess — wrap intry/catch(already done in snippets above) - For SPAs, navigate to each route and re-run extractions — styles may vary per page
CSS to Tailwind Token Mapping
Translate raw CSS computed values extracted from a live site into Tailwind CSS variables and utility classes.
Colours
rgb() / rgba() → HSL CSS variable
Tailwind v4 uses HSL for semantic tokens. Convert extracted rgb() values:
| Extracted (rgb) | Hex | HSL | Token |
|---|---|---|---|
rgb(13, 148, 136) |
#0D9488 |
174 90% 31% |
--primary |
rgb(255, 255, 255) |
#FFFFFF |
0 0% 100% |
--background |
rgb(17, 24, 39) |
#111827 |
221 39% 11% |
--foreground |
rgb(107, 114, 128) |
#6B7280 |
220 9% 46% |
--muted-foreground |
rgb(229, 231, 235) |
#E5E7EB |
220 14% 96% |
--border |
Quick HSL converter (run in browser):
function toHsl(rgb) {
const [r, g, b] = rgb.match(/\d+/g).map(Number).map(n => n / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
Semantic token assignment
Map extracted colours to these standard tokens:
| Token | Description | Typical source element |
|---|---|---|
--background |
Page background | body bg |
--foreground |
Default text | body color |
--primary |
Main brand / CTA | Primary button bg |
--primary-foreground |
Text on primary | Primary button text |
--secondary |
Secondary actions | Secondary button bg |
--secondary-foreground |
Text on secondary | Secondary button text |
--muted |
Subtle backgrounds | Card, sidebar bg |
--muted-foreground |
De-emphasised text | Captions, placeholders |
--accent |
Highlight/hover | Link hover, tag bg |
--accent-foreground |
Text on accent | — |
--border |
Dividers, outlines | border-color of inputs |
--ring |
Focus ring | :focus outline |
--destructive |
Errors, delete | Error text / button |
--destructive-foreground |
Text on destructive | — |
--card |
Card surface | Card component bg |
--card-foreground |
Text on card | Card text color |
Token Decision Tree
When a colour does not obviously map to a single token, walk this tree:
Is it the most prominent interactive colour (CTA button bg, main link)?
YES → --primary
NO ↓
Is it used on a second type of button or a less prominent interactive element?
YES → --secondary
NO ↓
Is it used for hover states, tag backgrounds, or inline highlights only?
YES → --accent
NO ↓
Is it a subtle background (sidebar, card surface, input field bg)?
YES → --muted (text on it → --muted-foreground)
NO ↓
Is it used on borders, dividers, or form input outlines?
YES → --border
NO ↓
Is it the page-level background?
YES → --background (text on it → --foreground)
NO → define a custom token (e.g. --surface, --panel, --hero-bg)
Tie-breaking rules:
--secondaryvs--accent: prefer--accentfor decorative hover/highlight effects; use--secondarywhen the element has an interactive affordance (click, focus, etc.).:hovercolours:getComputedStylemay silently return the resting state if the pointer is not physically over the element. Always verify against the base state.- Dark mode: if extraction was performed with the OS in dark mode, all
background/foreground values will be dark-mode variants. Re-extract in light mode
or document which mode was active in
docs/theme/overview.md. - Colours appearing only in
@media (prefers-color-scheme: dark)blocks belong indocs/theme/dark-mode.md, not the main token map.
Typography
Font family
| Extracted value | Tailwind token | CSS variable |
|---|---|---|
"Inter", sans-serif |
font-sans |
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif |
"Playfair Display", serif |
font-serif |
--font-serif: 'Playfair Display', ui-serif, Georgia, serif |
"JetBrains Mono", monospace |
font-mono |
--font-mono: 'JetBrains Mono', ui-monospace, monospace |
Define in @theme inline:
@theme inline {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
Font size scale
Map extracted px values to Tailwind size tokens:
| px | rem | Tailwind class |
|---|---|---|
| 12px | 0.75rem | text-xs |
| 14px | 0.875rem | text-sm |
| 16px | 1rem | text-base |
| 18px | 1.125rem | text-lg |
| 20px | 1.25rem | text-xl |
| 24px | 1.5rem | text-2xl |
| 30px | 1.875rem | text-3xl |
| 36px | 2.25rem | text-4xl |
| 48px | 3rem | text-5xl |
| 60px | 3.75rem | text-6xl |
If the site uses a non-standard scale, define custom tokens:
@theme inline {
--text-display: 3.5rem;
}
Font weight
| Extracted | Tailwind class |
|---|---|
| 300 | font-light |
| 400 | font-normal |
| 500 | font-medium |
| 600 | font-semibold |
| 700 | font-bold |
| 800 | font-extrabold |
| 900 | font-black |
Line height
| Extracted | Tailwind class |
|---|---|
| 1 | leading-none |
| 1.25 | leading-tight |
| 1.375 | leading-snug |
| 1.5 | leading-normal |
| 1.625 | leading-relaxed |
| 2 | leading-loose |
Spacing
Map extracted px values to Tailwind spacing scale (1 unit = 0.25rem = 4px):
| px | Tailwind | Common use |
|---|---|---|
| 4px | p-1 |
Tight padding |
| 8px | p-2 |
Small padding |
| 12px | p-3 |
— |
| 16px | p-4 |
Default padding |
| 20px | p-5 |
— |
| 24px | p-6 |
Section padding |
| 32px | p-8 |
Card padding |
| 40px | p-10 |
— |
| 48px | p-12 |
Section gap |
| 64px | p-16 |
Large section |
| 80px | p-20 |
Hero padding |
| 96px | p-24 |
Extra large |
| 128px | p-32 |
— |
For non-standard values, use CSS variables:
@theme inline {
--spacing-section: 5rem; /* 80px */
}
Border Radius
| Extracted | Tailwind class |
|---|---|
| 2px | rounded-sm |
| 4px | rounded |
| 6px | rounded-md |
| 8px | rounded-lg |
| 12px | rounded-xl |
| 16px | rounded-2xl |
| 9999px | rounded-full |
Define a project-wide radius token:
:root {
--radius: 0.5rem; /* matches the site's rounded-lg */
}
@theme inline {
--radius-default: var(--radius);
}
Shadows
| Description | Tailwind class |
|---|---|
| None | shadow-none |
| Subtle card | shadow-sm |
| Card / panel | shadow or shadow-md |
| Elevated modal | shadow-lg |
| Overlay / dropdown | shadow-xl |
| Dramatic | shadow-2xl |
For custom shadows, extract the exact box-shadow value and define:
@theme inline {
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
Complete src/index.css Template
@import "tailwindcss";
/* ── Semantic tokens ── */
:root {
/* Colours (fill from extraction) */
--background: hsl(0 0% 100%);
--foreground: hsl(221 39% 11%);
--primary: hsl(174 90% 31%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(210 40% 96%);
--secondary-foreground: hsl(222 47% 11%);
--muted: hsl(210 40% 96%);
--muted-foreground: hsl(215 16% 47%);
--accent: hsl(210 40% 96%);
--accent-foreground: hsl(222 47% 11%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(214 32% 91%);
--ring: hsl(174 90% 31%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(221 39% 11%);
/* Shape */
--radius: 0.5rem;
}
/* ── Map to Tailwind utilities ── */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
/* Typography */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
/* ── Base styles ── */
@layer base {
*, *::before, *::after { box-sizing: border-box; }
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
}
Verification Guide
How to interpret visual diffs and fix mismatches when comparing the original site to your local or deployed build.
Quick Start
# Start local dev server first
bun run dev
agent-browser
# Screenshot both sides
agent-browser --session source open <TARGET_URL> && \
agent-browser --session source wait --load networkidle && \
agent-browser --session source screenshot --full "${ARTIFACTS}/verify-source.png"
agent-browser --session local open http://localhost:5173 && \
agent-browser --session local wait --load networkidle && \
agent-browser --session local screenshot --full "${ARTIFACTS}/verify-local.png"
# Side-by-side pixel diff
agent-browser diff url <TARGET_URL> http://localhost:5173 \
--screenshot "${ARTIFACTS}/diff-homepage.png"
mcp-playwright
With mcp-playwright, omit filename to receive screenshots inline in context — the agent can read and reason about the images directly without saving files or running a diff tool.
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # source — visible inline
playwright_browser_navigate(url: "http://localhost:5173")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # local — visible inline
Both images appear in the agent’s context. Describe observed differences (colour, font, spacing, layout) directly — no pixel-diff tooling required.
Reading the diff output
agent-browser diff
agent-browser diff url produces:
- A diff image — changed pixels highlighted in red/pink. Unchanged areas appear greyed out.
- A mismatch percentage — percentage of pixels that differ.
mcp-playwright (inline)
There is no mismatch percentage. Instead, visually inspect both screenshots in context and note:
- Colour differences (wrong shade, missing tint)
- Font rendering differences (wrong family, weight, size)
- Spacing differences (too tight, too loose, wrong section height)
- Missing or misaligned components
Mismatch thresholds (agent-browser diff)
| Percentage | Interpretation |
|---|---|
| < 5% | Acceptable — minor rendering differences |
| 5–20% | Review needed — likely font, spacing, or colour drift |
| 20–50% | Significant — section layout or component structure differs |
| > 50% | Major — probably wrong page, wrong viewport, or missing content |
Common Mismatches and Fixes
Wrong font rendering
Symptom: Text looks wider/narrower, slightly different weight.
Cause: Font not loaded, fallback font used, or font-display: swap delay.
Fix:
- Check
<link>inindex.htmlincludes the Google Fonts URL withdisplay=swap - Verify
--font-sansin@theme inlinematches the extracted font family name exactly - Check the weight range: Google Fonts URLs must include all needed weights (e.g.
wght@400;600;700)
<!-- Correct -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
Colour drift
Symptom: Buttons, backgrounds, or text appear in the wrong shade.
Cause: HSL conversion was off, or wrong token assigned.
Fix:
- Re-run the colour extraction snippet from
references/extraction.md - Convert the
rgb()value to hex using the HSL converter - Check the assigned token — verify
--primaryis really the CTA colour, not a background
Quick browser check:
agent-browser:
agent-browser eval --stdin <<'JS'
const btn = document.querySelector('button, [class*="btn"]');
getComputedStyle(btn).backgroundColor
JS
mcp-playwright:
playwright_browser_evaluate(function: '() => { const btn = document.querySelector("button, [class*=\\"btn\\"]"); return getComputedStyle(btn).backgroundColor; }')
Spacing too tight or too loose
Symptom: Sections have wrong padding/margin compared to original.
Cause: Extracted spacing values weren’t translated to the correct Tailwind scale.
Fix:
- Re-run the spacing extraction snippet
- Convert px to rem (divide by 16), then map to the Tailwind spacing scale
- For irregular values, use arbitrary syntax temporarily:
py-[72px], then define a token
Missing section or wrong layout order
Symptom: A section visible on the original doesn’t appear locally.
Cause: Unimplemented component, wrong route, or different page structure.
Fix: This is a content/structure issue, not a theme issue. Skip for the theme porting phase — focus on visual fidelity of what is present.
Border radius mismatch
Symptom: Buttons/cards are more or less rounded.
Cause: --radius token set to wrong value.
Fix: Extract the exact value:
agent-browser:
agent-browser eval 'getComputedStyle(document.querySelector("button")).borderRadius'
mcp-playwright:
playwright_browser_evaluate(function: '() => getComputedStyle(document.querySelector("button")).borderRadius')
Then update --radius in :root.
Shadow too heavy or missing
Symptom: Cards look flat or over-shadowed.
Cause: Tailwind’s built-in shadow scale doesn’t match.
Fix: Extract the exact box-shadow:
agent-browser:
agent-browser eval --stdin <<'JS'
const card = document.querySelector('[class*="card"], article');
card ? getComputedStyle(card).boxShadow : 'not found'
JS
mcp-playwright:
playwright_browser_evaluate(function: '() => { const card = document.querySelector("[class*=\\"card\\"], article"); return card ? getComputedStyle(card).boxShadow : "not found"; }')
Then define a custom token:
@theme inline {
--shadow-card: 0 1px 3px rgb(0 0 0 / 0.08);
}
Icon or image sizes different
Symptom: Logo or icons are bigger/smaller than original.
Cause: Missing width/height constraints, or SVG viewBox differences.
Fix: This is a component-level issue. Note the pixel dimensions from the source screenshot and apply them directly in the component.
Multi-Page Verification
Check each route independently.
agent-browser:
PAGES=("" "/about" "/services" "/pricing" "/contact")
for PAGE in "${PAGES[@]}"; do
SLUG="${PAGE//\//-}"
SLUG="${SLUG:-home}"
agent-browser diff url "<TARGET_URL>${PAGE}" "http://localhost:5173${PAGE}" \
--screenshot "${ARTIFACTS}/diff${SLUG}.png"
done
mcp-playwright (inline — navigate to each page pair in sequence):
# For each page: screenshot source, then local — inspect inline
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_take_screenshot(fullPage: true)
playwright_browser_navigate(url: "http://localhost:5173")
playwright_browser_take_screenshot(fullPage: true)
# Repeat for /about, /services, etc.
Mobile Verification
agent-browser:
# Source at 375px
agent-browser --session source set viewport 375 812
agent-browser --session source open <TARGET_URL>
agent-browser --session source wait --load networkidle
agent-browser --session source screenshot --full "${ARTIFACTS}/verify-source-mobile.png"
# Local at 375px
agent-browser --session local set viewport 375 812
agent-browser --session local open http://localhost:5173
agent-browser --session local wait --load networkidle
agent-browser --session local screenshot --full "${ARTIFACTS}/verify-local-mobile.png"
# Diff
agent-browser diff screenshot \
--baseline "${ARTIFACTS}/verify-source-mobile.png" \
"${ARTIFACTS}/verify-local-mobile.png"
mcp-playwright:
playwright_browser_resize(width: 375, height: 812)
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # source mobile — inline
playwright_browser_navigate(url: "http://localhost:5173")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # local mobile — inline
Deployed Verification (Vercel)
When a preview URL is available.
agent-browser:
PREVIEW_URL="https://your-project-abc123.vercel.app"
agent-browser diff url <TARGET_URL> "${PREVIEW_URL}" \
--screenshot "${ARTIFACTS}/diff-deployed-homepage.png"
mcp-playwright:
playwright_browser_navigate(url: "<TARGET_URL>")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # source — inline
playwright_browser_navigate(url: "https://your-project-abc123.vercel.app")
playwright_browser_wait_for(time: 2)
playwright_browser_take_screenshot(fullPage: true) # deployed — inline
Iteration Loop
- Run diff → note red areas
- Identify which token/component causes the mismatch (use the quick-fix table above)
- Update
src/index.cssor the affected component - Rebuild:
bun run build && bun run preview - Re-run diff
- Repeat until mismatch % is acceptable
Aim for < 10% mismatch on primary pages (home, about) before considering the port complete.