├── .github ├── ISSUE_TEMPLATE │ └── custom.md └── workflows │ └── brand-challenge.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── examples ├── README.md ├── carbon.design.json ├── material.io.json └── stripe.com.json ├── index.js ├── lib ├── display.js └── extractors.js ├── package-lock.json ├── package.json ├── run-no-login-challenge.mjs └── showcase.png /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **WHAT** 11 | 12 | **WHY** 13 | 14 | **HOW** 15 | -------------------------------------------------------------------------------- /.github/workflows/brand-challenge.yml: -------------------------------------------------------------------------------- 1 | # ←←← COPY FROM HERE ↓↓↓ 2 | name: Dembrandt Global Challenge 3 | 4 | on: 5 | push: 6 | branches: [develop] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | challenge: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 40 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 22 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 22 22 | cache: npm 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Run Dembrandt Global Challenge 28 | continue-on-error: true 29 | run: | 30 | echo "Starting Dembrandt Global Hardest Sites Challenge (no login)..." 31 | npm run brand-challenge:report > challenge-report.txt 2>&1 32 | SCORE=$(grep -o '[0-9]\+/100' challenge-report.txt | tail -1 || echo "0/100") 33 | echo "SCORE=$SCORE" >> $GITHUB_ENV 34 | 35 | - name: Upload full report 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: dembrandt-challenge-report 39 | path: challenge-report.txt 40 | 41 | - name: GitHub Actions Summary 42 | run: | 43 | echo "## Dembrandt Global Challenge Result" >> $GITHUB_STEP_SUMMARY 44 | echo "### Score: **${{ env.SCORE }}**" >> $GITHUB_STEP_SUMMARY 45 | if [[ "${{ env.SCORE }}" == "100/100" ]]; then 46 | echo "🏆 **LEGENDARY** — You beat the 10 hardest public sites on Earth!" >> $GITHUB_STEP_SUMMARY 47 | elif [[ "${{ env.SCORE }}" == "90/100" ]]; then 48 | echo "⭐ **ELITE** — World-class extractor" >> $GITHUB_STEP_SUMMARY 49 | elif [[ "${{ env.SCORE }}" =~ ^[7-9][0-9]/100$ ]]; then 50 | echo "💪 **STRONG** — Better than 99% of public tools" >> $GITHUB_STEP_SUMMARY 51 | else 52 | echo "🔧 **IMPROVING** — Keep going!" >> $GITHUB_STEP_SUMMARY 53 | fi 54 | echo "" >> $GITHUB_STEP_SUMMARY 55 | 56 | # Extract summary stats only (not full verbose output) 57 | echo "### Results by Site" >> $GITHUB_STEP_SUMMARY 58 | echo "" >> $GITHUB_STEP_SUMMARY 59 | echo "| Site | Status | Time |" >> $GITHUB_STEP_SUMMARY 60 | echo "|------|--------|------|" >> $GITHUB_STEP_SUMMARY 61 | 62 | # Parse results from the report 63 | grep -E "\[([0-9]+)/([0-9]+)\] Testing →|SUCCESS|FAILED" challenge-report.txt | \ 64 | awk '/Testing →/ {site=$NF; getline; getline; if(/SUCCESS/) {print "| " site " | ✅ Success | " $3 " |"} else if(/FAILED/) {print "| " site " | ❌ Failed | " $3 " |"}}' \ 65 | >> $GITHUB_STEP_SUMMARY || echo "| - | - | - |" >> $GITHUB_STEP_SUMMARY 66 | 67 | echo "" >> $GITHUB_STEP_SUMMARY 68 | echo "📊 **Full report available in artifacts** (download above)" >> $GITHUB_STEP_SUMMARY 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Editor 5 | .vscode/ 6 | .idea/ 7 | 8 | # OS 9 | .DS_Store 10 | 11 | # Environment 12 | .env 13 | .env.local 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Output 20 | output/ 21 | test-output/ 22 | 23 | # Don't commit example outputs (but allow examples/ directory) 24 | *.json 25 | !package*.json 26 | !examples/*.json 27 | 28 | # Coverage 29 | coverage/ 30 | 31 | .claude 32 | 33 | CLAUDE.md 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .gitattributes 5 | 6 | # CI/CD 7 | .github/ 8 | 9 | # Editor 10 | .vscode/ 11 | .idea/ 12 | *.swp 13 | *.swo 14 | *~ 15 | 16 | # OS 17 | .DS_Store 18 | Thumbs.db 19 | 20 | # Environment 21 | .env 22 | .env.* 23 | 24 | # Logs 25 | *.log 26 | npm-debug.log* 27 | 28 | # Output 29 | output/ 30 | 31 | # Examples (users should check repo for these) 32 | examples/ 33 | 34 | # Media (not needed in npm package) 35 | showcase.png 36 | *.mp4 37 | *.mov 38 | 39 | # Development 40 | .claude/ 41 | coverage/ 42 | test/ 43 | tests/ 44 | *.test.js 45 | *.spec.js 46 | 47 | # Documentation (README is needed, but not extra docs) 48 | docs/ 49 | CONTRIBUTING.md 50 | 51 | # Misc 52 | .npmrc 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.3.0] - 2025-11-24 4 | 5 | ### Added 6 | - `--slow` flag for slow-loading sites with 3x longer timeouts 7 | - Tailwind CSS exporter (`lib/exporters.js`) 8 | - Brand challenge test suite against SPA/heavy-JS sites (Tesla, Dribbble, SoundCloud, Airtable, Product Hunt, Behance) 9 | - GitHub Actions CI workflow for automated testing 10 | - Border detection with confidence scoring 11 | 12 | ### Changed 13 | - Improved terminal output with tree structure 14 | - Enhanced retry logic for empty content 15 | - Better SPA hydration detection 16 | - Test suite refocused on SPA and interactive sites 17 | - Lowered content validation threshold from 500 to 100 chars for minimal-text sites 18 | - Clearer border style display with `(per-side)` label for shorthand values 19 | - Shadows now sorted by confidence and usage frequency (most confident first) 20 | - Button detection now includes outline/bordered buttons (previously skipped transparent backgrounds) 21 | 22 | ## [0.2.0] - 2025-11-22 23 | 24 | ### Added 25 | - `--dark-mode` and `--mobile` flags 26 | - Clickable terminal links 27 | - Enhanced bot detection avoidance 28 | 29 | ## [0.1.0] - 2025-11-21 30 | 31 | Initial public release 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Dembrandt 2 | 3 | Dembrandt reveals any website’s brand the way Rembrandt revealed light from shadow... color, typography, tone, emotion, and drama in seconds. 4 | 5 | De-em-brand-t is the smartest way to dissect a brand, design system and design tokens from any website. Useful for various use cases. 6 | 7 | I’m @thevangelist, and I welcome you to develop this creative tool with me. Be it stargazers, dreamers, bug hunters, or just local hobos. 8 | 9 | Please tell me about: 10 | 11 | - Bugs you found 12 | - Weird websites that make it cry 13 | - Pull requests (even one-liners make me happy) 14 | - Open a discussion, I’ll talk to you, promise 15 | - Graphic design workflow ideas 16 | - Dev workflow pain 17 | - Missing design tokens 18 | - Marketing–design–dev chaos 19 | - Design systems you love/hate 20 | - How to use more AI without selling our souls 21 | - Tone of voice obsessions 22 | - How you wish to use this thing 23 | - Your latest dream (or nightmare) 24 | 25 | I am quite easy to work with. Really. 26 | Spam me in Issues, PRs, or anywhere. I reply to everything. 27 | 28 | @thevangelist. 29 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | This guide covers development workflows, testing, and contribution guidelines for Dembrandt. 4 | 5 | ## Setup 6 | 7 | 1. Clone the repository: 8 | ```bash 9 | git clone https://github.com/thevangelist/dembrandt.git 10 | cd dembrandt 11 | ``` 12 | 13 | 2. Install dependencies: 14 | ```bash 15 | npm install 16 | ``` 17 | 18 | 3. Install Playwright browser: 19 | ```bash 20 | npm run install-browser 21 | ``` 22 | 23 | ## Development Commands 24 | 25 | ```bash 26 | # Run locally 27 | node index.js 28 | 29 | # Run with options 30 | node index.js stripe.com --debug # Visible browser 31 | node index.js stripe.com --json-only # JSON only 32 | node index.js stripe.com --slow # 3x timeouts 33 | 34 | # Brand challenge suite (tests against complex sites) 35 | npm run brand-challenge 36 | 37 | # Version diff test (compare npm vs main branch) 38 | # Use Claude Code slash command: /test-version-diff stripe.com 39 | ``` 40 | 41 | ## Testing 42 | 43 | ### Version Diff Test 44 | 45 | Compare output between the latest npm release and the main branch to catch regressions before publishing. 46 | 47 | **Using Claude Code (recommended):** 48 | 49 | ```bash 50 | # In Claude Code, use the slash command: 51 | /test-version-diff stripe.com 52 | 53 | # Or just: 54 | /test-version-diff 55 | # (Claude will ask for the domain) 56 | ``` 57 | 58 | **What it does:** 59 | 1. Runs the latest npm release version (`npx dembrandt@latest`) against your domain 60 | 2. Runs the current main branch version against the same domain 61 | 3. Compares the JSON outputs and shows differences 62 | 4. Saves all outputs and diff to `test-output/-/` 63 | 64 | **Output files:** 65 | - `npm-release.json` - Output from latest npm version 66 | - `main-branch.json` - Output from current main branch 67 | - `npm-release-formatted.json` - Sorted JSON for npm version 68 | - `main-branch-formatted.json` - Sorted JSON for main branch 69 | - `diff.txt` - Line-by-line differences (if any) 70 | 71 | **Example outputs:** 72 | 73 | ✅ **No differences:** 74 | ``` 75 | 🧪 Testing version diff for: stripe.com 76 | 📁 Output directory: ./test-output/stripe.com-20250128-143022 77 | 78 | 📦 Running latest npm release version... 79 | ✅ NPM version output saved to: ./test-output/stripe.com-20250128-143022/npm-release.json 80 | 81 | 🔨 Running current main branch version... 82 | ✅ Main branch output saved to: ./test-output/stripe.com-20250128-143022/main-branch.json 83 | 84 | 📊 Comparing outputs... 85 | 86 | ✅ No differences found! Outputs are identical. 87 | 88 | 📁 All files saved to: ./test-output/stripe.com-20250128-143022 89 | ``` 90 | 91 | 📝 **With differences:** 92 | ``` 93 | 📊 Comparing outputs... 94 | 95 | 📝 Differences found: 96 | 97 | --- ./test-output/stripe.com-20250128-143022/npm-release-formatted.json 98 | +++ ./test-output/stripe.com-20250128-143022/main-branch-formatted.json 99 | @@ -45,7 +45,10 @@ 100 | "value": "1px", 101 | "count": 33, 102 | "confidence": "high" 103 | - } 104 | + }, 105 | + "combinations": [ 106 | + { "width": "1px", "style": "solid", "color": "#e0e0e0" } 107 | + ] 108 | ], 109 | "typography": { 110 | 111 | 💾 Full diff saved to: ./test-output/stripe.com-20250128-143022/diff.txt 112 | ``` 113 | 114 | **Use cases:** 115 | - Before releases to ensure changes don't break existing functionality 116 | - After major refactoring to verify output consistency 117 | - When debugging extraction issues to compare behavior 118 | - To document intentional changes in output format 119 | 120 | ### Brand Challenge Suite 121 | 122 | Test against complex SPA/interactive sites with heavy JavaScript: 123 | 124 | ```bash 125 | npm run brand-challenge 126 | ``` 127 | 128 | Tests against: 129 | - Tesla (WebGL/3D, bot protection) 130 | - Dribbble (interactive previews) 131 | - SoundCloud (SPA, media streaming) 132 | - Airtable (SaaS grids, dynamic content) 133 | - Product Hunt (SPA, async listings) 134 | - Behance (portfolios, AJAX content) 135 | 136 | Uses `--slow` flag for 3x timeouts (24s hydration, 12s stabilization). 137 | 138 | ## Project Structure 139 | 140 | ``` 141 | dembrandt/ 142 | ├── index.js # CLI entry point 143 | ├── lib/ 144 | │ ├── extractors.js # Core extraction functions 145 | │ └── display.js # Terminal output formatting 146 | ├── test-version-diff.mjs # Version comparison test 147 | ├── test-version-diff.sh # Version comparison test (bash) 148 | ├── run-no-login-challenge.mjs # Brand challenge suite 149 | ├── examples/ # Example outputs 150 | └── output/ # Extraction outputs 151 | ``` 152 | 153 | ## Architecture 154 | 155 | See [CLAUDE.md](CLAUDE.md) for detailed architecture documentation, including: 156 | - Entry point flow and browser lifecycle 157 | - Core extraction engine and parallelization 158 | - Anti-bot protection strategies 159 | - Color confidence scoring 160 | - Display layer formatting 161 | 162 | ## Release Process 163 | 164 | 1. **Test changes:** 165 | ```bash 166 | npm run test:version-diff stripe.com 167 | npm run brand-challenge 168 | ``` 169 | 170 | 2. **Update version:** 171 | ```bash 172 | # Edit package.json version manually 173 | git add package.json 174 | git commit -m "bump: version x.y.z" 175 | ``` 176 | 177 | 3. **Create git tag:** 178 | ```bash 179 | git tag -a vx.y.z -m "Release vx.y.z - Description" 180 | git push origin main --tags 181 | ``` 182 | 183 | 4. **Publish to npm:** 184 | ```bash 185 | npm publish 186 | ``` 187 | 188 | 5. **Verify:** 189 | ```bash 190 | npx dembrandt@latest stripe.com 191 | ``` 192 | 193 | ## Contributing Guidelines 194 | 195 | 1. **Code style:** 196 | - Use ES modules (`import`/`export`) 197 | - Prefer async/await over promises 198 | - Add JSDoc comments for public functions 199 | - Follow existing formatting conventions 200 | 201 | 2. **Adding extractors:** 202 | - Add function to `lib/extractors.js` 203 | - Add to `Promise.all` in `extractBranding()` 204 | - Add display function to `lib/display.js` 205 | - Add call in `displayResults()` 206 | 207 | 3. **Testing:** 208 | - Test against multiple sites (simple and complex) 209 | - Run brand challenge suite 210 | - Run version diff test against known-good sites 211 | - Test with `--debug` flag to see browser behavior 212 | 213 | 4. **Pull requests:** 214 | - Include test results in PR description 215 | - Document breaking changes clearly 216 | - Update CHANGELOG.md if applicable 217 | - Add examples for new features 218 | 219 | ## Common Development Tasks 220 | 221 | ### Adding a new extraction function 222 | 223 | 1. Create async function in `lib/extractors.js`: 224 | ```javascript 225 | async function extractNewFeature(page) { 226 | return await page.evaluate(() => { 227 | // DOM analysis in browser context 228 | return { /* extracted data */ }; 229 | }); 230 | } 231 | ``` 232 | 233 | 2. Add to extraction pipeline in `extractBranding()`: 234 | ```javascript 235 | const results = await Promise.all([ 236 | extractLogo(page), 237 | extractColors(page), 238 | extractNewFeature(page), // <-- Add here 239 | // ... other extractors 240 | ]); 241 | ``` 242 | 243 | 3. Add display function in `lib/display.js`: 244 | ```javascript 245 | function displayNewFeature(data) { 246 | if (!data) return; 247 | console.log(chalk.dim('├─') + ' ' + chalk.bold('New Feature')); 248 | // ... formatting 249 | } 250 | ``` 251 | 252 | 4. Call display function in `displayResults()`: 253 | ```javascript 254 | displayNewFeature(data.newFeature); 255 | ``` 256 | 257 | ### Modifying confidence scoring 258 | 259 | Edit `contextScores` object in `extractColors()` or similar scoring logic: 260 | 261 | ```javascript 262 | const contextScores = { 263 | logo: 5, // Highest confidence 264 | brand: 5, 265 | primary: 4, 266 | button: 3, 267 | // ... add your contexts 268 | }; 269 | ``` 270 | 271 | ### Adjusting timeouts 272 | 273 | Timeouts use `timeoutMultiplier` (3x when `--slow` is used): 274 | 275 | ```javascript 276 | const timeoutMultiplier = options.slow ? 3 : 1; 277 | 278 | // Navigation timeout 279 | await page.goto(url, { 280 | timeout: 20000 * timeoutMultiplier, 281 | waitUntil: 'networkidle', 282 | }); 283 | 284 | // Hydration wait 285 | await page.waitForTimeout(8000 * timeoutMultiplier); 286 | ``` 287 | 288 | ## Debugging Tips 289 | 290 | 1. **Use `--debug` flag** to see browser: 291 | ```bash 292 | node index.js stripe.com --debug 293 | ``` 294 | 295 | 2. **Check extraction step by step:** 296 | ```javascript 297 | // Add console.log in extractors.js 298 | console.log('Extracted colors:', colors); 299 | ``` 300 | 301 | 3. **Test in browser console:** 302 | ```javascript 303 | // Copy extraction logic to browser DevTools 304 | document.querySelectorAll('button').length 305 | ``` 306 | 307 | 4. **Check bot detection:** 308 | ```bash 309 | # If site loads differently, likely bot detection 310 | node index.js site.com --debug 311 | # vs 312 | # Open site.com in regular Chrome 313 | ``` 314 | 315 | 5. **Verify output structure:** 316 | ```bash 317 | node index.js stripe.com --json-only 318 | cat output/stripe.com/latest.json | jq . 319 | ``` 320 | 321 | ## Performance Optimization 322 | 323 | - Extractors run in parallel using `Promise.all()` 324 | - DOM queries happen in browser context (`page.evaluate()`) 325 | - Minimize data transfer between Node and browser 326 | - Use CSS selectors efficiently 327 | - Cache repeated computations 328 | 329 | ## Security Considerations 330 | 331 | - Never expose API keys or credentials 332 | - Validate all user input (URLs) 333 | - Use stealth mode to avoid bot detection 334 | - Respect robots.txt and site ToS 335 | - Rate limit to avoid overwhelming servers 336 | 337 | ## Support 338 | 339 | - Issues: https://github.com/thevangelist/dembrandt/issues 340 | - Discussions: https://github.com/thevangelist/dembrandt/discussions 341 | - Email: info@esajuhana.com 342 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 thevangelist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎨 Dembrandt 2 | 3 | [![npm version](https://img.shields.io/npm/v/dembrandt.svg)](https://www.npmjs.com/package/dembrandt) 4 | [![npm downloads](https://img.shields.io/npm/dm/dembrandt.svg)](https://www.npmjs.com/package/dembrandt) 5 | [![license](https://img.shields.io/npm/l/dembrandt.svg)](https://github.com/thevangelist/dembrandt/blob/main/LICENSE) 6 | 7 | Extract any website’s design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command. 8 | 9 | ![Dembrandt Demo](showcase.png) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npx dembrandt stripe.com 15 | ``` 16 | 17 | Or install globally: `npm install -g dembrandt` then run `dembrandt stripe.com` 18 | 19 | Requires Node.js 18+ 20 | 21 | ## What to expect from extraction? 22 | 23 | - Colors (semantic, palette, CSS variables) 24 | - Typography (fonts, sizes, weights, sources) 25 | - Spacing (margin/padding scales) 26 | - Borders (radius, widths, styles, colors) 27 | - Shadows 28 | - Components (buttons, inputs, links) 29 | - Breakpoints 30 | - Icons & frameworks 31 | 32 | ## Usage 33 | 34 | ```bash 35 | dembrandt # Basic extraction 36 | dembrandt stripe.com --json-only # JSON output 37 | dembrandt site.com --debug # Visible browser 38 | dembrandt site.com --dark-mode # Dark mode 39 | dembrandt site.com --mobile # Mobile viewport 40 | dembrandt site.com --slow # 3x timeouts 41 | ``` 42 | 43 | Results auto-save to `output/domain.com/YYYY-MM-DDTHH-MM-SS.json` 44 | 45 | ## Use Cases 46 | 47 | - Brand audits & competitive analysis 48 | - Design system documentation 49 | - Reverse engineering brands 50 | - Multi-site brand consolidation 51 | 52 | ## How It Works 53 | 54 | Uses Playwright to render the page, extracts computed styles from the DOM, analyzes color usage and confidence, groups similar typography, detects spacing patterns, and returns actionable design tokens. 55 | 56 | ### Extraction Process 57 | 58 | 1. Browser Launch - Launches Chromium with stealth configuration 59 | 2. Anti-Detection - Injects scripts to bypass bot detection 60 | 3. Navigation - Navigates to target URL with retry logic 61 | 4. Hydration - Waits for SPAs to fully load (8s initial + 4s stabilization) 62 | 5. Content Validation - Verifies page content is substantial (>500 chars) 63 | 6. Parallel Extraction - Runs all extractors concurrently for speed 64 | 7. Analysis - Analyzes computed styles, DOM structure, and CSS variables 65 | 8. Scoring - Assigns confidence scores based on context and usage 66 | 67 | ### Color Confidence 68 | 69 | - High — Logo, brand elements, primary buttons 70 | - Medium — Interactive elements, icons, navigation 71 | - Low — Generic UI components (filtered from display) 72 | - Only shows high and medium confidence colors in terminal. Full palette in JSON. 73 | 74 | ## Limitations 75 | 76 | - Dark mode requires --dark-mode flag (not automatically detected) 77 | - Hover/focus states extracted from CSS (not fully interactive) 78 | - Canvas/WebGL-rendered sites cannot be analyzed (e.g., Tesla, Apple Vision Pro demos) 79 | - JavaScript-heavy sites require hydration time (8s initial + 4s stabilization) 80 | - Some dynamically-loaded content may be missed 81 | - Default viewport is 1920x1080 (use --mobile for responsive analysis) 82 | 83 | ## Ethics & Legality 84 | 85 | Dembrandt extracts publicly available design information (colors, fonts, spacing) from website DOMs for analysis purposes. This falls under fair use in most jurisdictions (USA's DMCA § 1201(f), EU Software Directive 2009/24/EC) when used for competitive analysis, documentation, or learning. 86 | 87 | Legal: Analyzing public HTML/CSS is generally legal. Does not bypass protections or violate copyright. Check site ToS before mass extraction. 88 | 89 | Ethical: Use for inspiration and analysis, not direct copying. Respect servers (no mass crawling), give credit to sources, be transparent about data origin. 90 | 91 | ## Contributing 92 | 93 | Bugs you found? Weird websites that make it cry? Pull requests (even one-liners make me happy)? 94 | 95 | Spam me in [Issues](https://github.com/thevangelist/dembrandt/issues) or PRs. I reply to everything. 96 | 97 | Let's keep the light alive together. 98 | 99 | @thevangelist 100 | 101 | --- 102 | 103 | MIT — do whatever you want with it. 104 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Dembrandt Examples 2 | 3 | This folder contains real-world examples of design token extraction from popular websites. Each JSON file demonstrates Dembrandt's ability to extract colors, typography, spacing, shadows, and component styles from production sites. 4 | 5 | ## Examples 6 | 7 | ### 1. [material.io.json](material.io.json) - Google Material Design 3 8 | 9 | **Why this matters:** Google's Material Design is the foundation for Android and countless web apps. This extraction captures Material 3's latest design tokens. 10 | 11 | **Key findings:** 12 | - **Primary color**: `rgb(100, 66, 214)` - Material's signature purple 13 | - **Typography**: Google Sans & Google Sans Text font families 14 | - Display: 96px / 6rem (h1) 15 | - Headline: 57px / 3.56rem (h2) 16 | - Body: 16px / 1rem with 500 weight for links 17 | - **Spacing scale**: 8px base grid system (8px, 16px, 24px, 32px, 64px, 96px) 18 | - **Border radius**: 24px (high confidence), 16px, 4px - Material's rounded corners 19 | - **Components**: 3 button variants detected 20 | - Primary: Purple fill (`rgb(100, 66, 214)`) with 48px border radius 21 | - Container: Light purple background 22 | - Text-only: Transparent background 23 | 24 | **Design system characteristics:** 25 | - Clean, semantic color palette with 15 unique colors 26 | - CSS variables for theming (`--mio-theme-color-*`) 27 | - 6 responsive breakpoints (600px, 960px, 1294px) 28 | - Bootstrap framework patterns detected 29 | 30 | --- 31 | 32 | ### 2. [carbon.design.json](carbon.design.json) - IBM Carbon Design System 33 | 34 | **Why this matters:** IBM Carbon is built for enterprise applications with accessibility and consistency at its core. Perfect example of a mature, production design system. 35 | 36 | **Key findings:** 37 | - **Color palette**: Enterprise-grade neutrals 38 | - Primary: `rgb(0, 0, 0)` - 272 instances 39 | - Gray scale: `rgb(40, 40, 40)` (197 instances), `rgb(78, 78, 78)` (71 instances) 40 | - Minimal accent colors - enterprise aesthetic 41 | - **Typography**: IBM Plex family 42 | - Sans: 32px / 2rem (medium weight) 43 | - Mono: 14px / 0.875rem (400 weight) - for code/data 44 | - Text: 16px / 1rem - body text 45 | - **Spacing scale**: Conservative, accessible spacing 46 | - 8px, 16px, 24px, 32px (4px not as prominent as Material) 47 | - **Components**: Minimal button styles (1 variant) - emphasizes function over form 48 | 49 | **Design system characteristics:** 50 | - Highly accessible (10 unique colors, high contrast) 51 | - 2 frameworks detected (React + custom components) 52 | - Professional, minimal aesthetic 53 | - Perfect for data-heavy enterprise UIs 54 | 55 | --- 56 | 57 | ### 3. [stripe.com.json](stripe.com.json) - Stripe Payment Platform 58 | 59 | **Why this matters:** Stripe's design is polished, conversion-focused, and represents modern SaaS UI best practices. 60 | 61 | **Key findings:** 62 | - **Rich color palette**: 57 unique colors (most diverse of the three) 63 | - Primary text: `rgb(66, 84, 102)` - 3,685 instances (slate blue) 64 | - Accent blues and purples throughout 65 | - Professional yet friendly color scheme 66 | - **Typography**: Custom sans-serif stack 67 | - 21 distinct typography styles (most complex) 68 | - Range from 12px to 80px 69 | - Sophisticated type scale for marketing + product 70 | - **Spacing scale**: 20 unique spacing values 71 | - Fine-grained control for pixel-perfect layouts 72 | - Mix of 4px and 8px base increments 73 | - **Border radius**: 28 different radius values 74 | - Highly polished, consistent rounding 75 | - Ranges from subtle (2px) to prominent (24px+) 76 | - **Shadows**: 14 shadow variants 77 | - Sophisticated depth system 78 | - Elevates CTAs and cards 79 | - **Components**: Rich component library 80 | - 1 button variant, 1 input style detected 81 | - SVG logo extracted (60x25px) 82 | - **Responsive**: 13 breakpoints - fine-tuned responsive design 83 | - **Icons**: 1 icon system detected 84 | 85 | **Design system characteristics:** 86 | - Most comprehensive extraction (101KB content analyzed) 87 | - Production-grade component library 88 | - Conversion-optimized design (fintech trust signals) 89 | - No framework detected (custom implementation) 90 | 91 | --- 92 | 93 | ## Comparison Table 94 | 95 | | Metric | Material.io | Carbon.design | Stripe.com | 96 | |--------|------------|---------------|------------| 97 | | **Colors** | 15 | 10 | 57 | 98 | | **Typography Styles** | 10 | 3 | 21 | 99 | | **Spacing Values** | 13 | 5 | 20 | 100 | | **Border Radius** | 8 | 1 | 28 | 101 | | **Shadows** | 1 | 0 | 14 | 102 | | **Buttons** | 3 | 1 | 1 | 103 | | **Breakpoints** | 6 | 0 | 13 | 104 | | **Design Philosophy** | Consumer-friendly, bold | Enterprise, accessible | Conversion-focused, polished | 105 | 106 | --- 107 | 108 | ## How These Were Generated 109 | 110 | Each example was generated with a single command: 111 | 112 | ```bash 113 | npx dembrandt material.io --json-only > examples/material.io.json 114 | npx dembrandt carbon.design --json-only > examples/carbon.design.json 115 | npx dembrandt stripe.com --json-only > examples/stripe.com.json 116 | ``` 117 | 118 | **Key capabilities demonstrated:** 119 | - ✅ Automatic redirect handling (material.io → m3.material.io) 120 | - ✅ SPA hydration detection (8-second wait for JavaScript-heavy sites) 121 | - ✅ Component analysis (buttons, inputs, etc.) 122 | - ✅ Framework detection (Bootstrap, React) 123 | - ✅ Logo extraction (SVG dimensions) 124 | - ✅ Responsive breakpoint discovery 125 | - ✅ CSS variable extraction 126 | 127 | --- 128 | 129 | ## What You Can Do With These 130 | 131 | **For designers:** 132 | - Import color palettes into Figma/Sketch 133 | - Understand competitor design systems 134 | - Build design system documentation 135 | 136 | **For developers:** 137 | - Generate Tailwind configs from extracted tokens 138 | - Audit brand consistency across sites 139 | - Reverse-engineer spacing scales for your own projects 140 | 141 | **For product teams:** 142 | - Competitive analysis of design patterns 143 | - Build design system guidelines 144 | - Document existing implementations 145 | 146 | --- 147 | 148 | ## Try It Yourself 149 | 150 | ```bash 151 | # Extract any site (no installation needed!) 152 | npx dembrandt yoursite.com 153 | 154 | # Export as JSON 155 | npx dembrandt yoursite.com --json-only > tokens.json 156 | ``` 157 | 158 | --- 159 | 160 | **🎨 These examples showcase how Dembrandt extracts production design tokens without requiring API access or authentication - just a URL.** 161 | -------------------------------------------------------------------------------- /examples/carbon.design.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://carbon.design/", 3 | "extractedAt": "2025-11-22T15:25:35.249Z", 4 | "logo": null, 5 | "colors": { 6 | "semantic": {}, 7 | "palette": [ 8 | { 9 | "color": "rgb(0, 0, 0)", 10 | "count": 272, 11 | "confidence": "high", 12 | "sources": [ 13 | "text_styles" 14 | ] 15 | }, 16 | { 17 | "color": "rgb(40, 40, 40)", 18 | "count": 197, 19 | "confidence": "high", 20 | "sources": [ 21 | "fancy-button" 22 | ] 23 | }, 24 | { 25 | "color": "rgb(0, 0, 238)", 26 | "count": 36, 27 | "confidence": "high", 28 | "sources": [ 29 | "external-link", 30 | "maglink" 31 | ] 32 | }, 33 | { 34 | "color": "rgb(233, 232, 231)", 35 | "count": 23, 36 | "confidence": "high", 37 | "sources": [ 38 | "link-1" 39 | ] 40 | }, 41 | { 42 | "color": "rgb(255, 255, 255)", 43 | "count": 16, 44 | "confidence": "medium", 45 | "sources": [] 46 | }, 47 | { 48 | "color": "rgba(0, 0, 0, 0.55)", 49 | "count": 9, 50 | "confidence": "medium", 51 | "sources": [] 52 | }, 53 | { 54 | "color": "rgba(237, 215, 23, 0)", 55 | "count": 9, 56 | "confidence": "medium", 57 | "sources": [] 58 | }, 59 | { 60 | "color": "rgba(255, 255, 255, 0)", 61 | "count": 2, 62 | "confidence": "low", 63 | "sources": [] 64 | }, 65 | { 66 | "color": "rgba(0, 0, 0, 0.85)", 67 | "count": 1, 68 | "confidence": "low", 69 | "sources": [] 70 | }, 71 | { 72 | "color": "rgba(0, 0, 0, 0.9)", 73 | "count": 1, 74 | "confidence": "low", 75 | "sources": [] 76 | } 77 | ], 78 | "cssVariables": {} 79 | }, 80 | "typography": { 81 | "styles": [ 82 | { 83 | "fontFamily": "-apple-system, system-ui, \"system-ui\", \"Segoe UI\", Roboto, Ubuntu, Arial, sans-serif", 84 | "fontSize": "16px", 85 | "fontSizeRem": "1.00rem", 86 | "fontWeight": "400", 87 | "lineHeight": "16px", 88 | "contexts": [ 89 | "a", 90 | "button" 91 | ], 92 | "confidence": "medium" 93 | }, 94 | { 95 | "fontFamily": "Inter", 96 | "fontSize": "18px", 97 | "fontSizeRem": "1.13rem", 98 | "fontWeight": "400", 99 | "lineHeight": "15px", 100 | "contexts": [ 101 | "p" 102 | ], 103 | "confidence": "medium" 104 | }, 105 | { 106 | "fontFamily": "Roboto", 107 | "fontSize": "12px", 108 | "fontSizeRem": "0.75rem", 109 | "fontWeight": "700", 110 | "lineHeight": "15px", 111 | "contexts": [ 112 | "a" 113 | ], 114 | "confidence": "medium" 115 | } 116 | ], 117 | "sources": { 118 | "googleFonts": [ 119 | "Roboto", 120 | "Roboto" 121 | ], 122 | "adobeFonts": false, 123 | "customFonts": [] 124 | } 125 | }, 126 | "spacing": { 127 | "scaleType": "8px", 128 | "commonValues": [ 129 | { 130 | "px": "1px", 131 | "rem": "0.06rem", 132 | "count": 13 133 | }, 134 | { 135 | "px": "16px", 136 | "rem": "1.00rem", 137 | "count": 2 138 | }, 139 | { 140 | "px": "9.375px", 141 | "rem": "0.59rem", 142 | "count": 1 143 | }, 144 | { 145 | "px": "84.375px", 146 | "rem": "5.27rem", 147 | "count": 1 148 | }, 149 | { 150 | "px": "161.25px", 151 | "rem": "10.08rem", 152 | "count": 1 153 | } 154 | ] 155 | }, 156 | "borderRadius": { 157 | "values": [ 158 | { 159 | "value": "8px", 160 | "count": 2, 161 | "confidence": "low" 162 | } 163 | ] 164 | }, 165 | "shadows": [], 166 | "components": { 167 | "buttons": [ 168 | { 169 | "backgroundColor": "rgba(0, 0, 0, 0)", 170 | "color": "rgb(40, 40, 40)", 171 | "padding": "0px", 172 | "borderRadius": "0px", 173 | "border": "0px none rgb(40, 40, 40)", 174 | "fontWeight": "400", 175 | "fontSize": "0px", 176 | "classes": "fancy-button", 177 | "confidence": "medium" 178 | } 179 | ], 180 | "inputs": [] 181 | }, 182 | "breakpoints": [], 183 | "iconSystem": [], 184 | "frameworks": [ 185 | { 186 | "name": "Tailwind CSS", 187 | "confidence": "high", 188 | "evidence": "class patterns" 189 | }, 190 | { 191 | "name": "Bootstrap", 192 | "confidence": "high", 193 | "evidence": "class patterns" 194 | } 195 | ] 196 | } 197 | -------------------------------------------------------------------------------- /examples/material.io.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://m3.material.io/", 3 | "extractedAt": "2025-11-22T15:23:05.644Z", 4 | "logo": null, 5 | "colors": { 6 | "semantic": { 7 | "primary": "rgb(248, 241, 246)" 8 | }, 9 | "palette": [ 10 | { 11 | "color": "rgb(28, 27, 29)", 12 | "count": 211, 13 | "confidence": "high", 14 | "sources": [ 15 | "primary-container", 16 | "section-header", 17 | "section-header-description", 18 | "social-links", 19 | "library-links", 20 | "google-logo", 21 | "legal-link" 22 | ] 23 | }, 24 | { 25 | "color": "rgb(0, 0, 0)", 26 | "count": 61, 27 | "confidence": "high", 28 | "sources": [] 29 | }, 30 | { 31 | "color": "rgb(77, 66, 86)", 32 | "count": 50, 33 | "confidence": "high", 34 | "sources": [ 35 | "section-link" 36 | ] 37 | }, 38 | { 39 | "color": "rgb(31, 31, 31)", 40 | "count": 28, 41 | "confidence": "high", 42 | "sources": [ 43 | "navbar-toggle-buttons" 44 | ] 45 | }, 46 | { 47 | "color": "rgb(100, 66, 214)", 48 | "count": 26, 49 | "confidence": "high", 50 | "sources": [ 51 | "skip-link", 52 | "mio-button" 53 | ] 54 | }, 55 | { 56 | "color": "rgb(248, 241, 246)", 57 | "count": 17, 58 | "confidence": "medium", 59 | "sources": [ 60 | "primary-container" 61 | ] 62 | }, 63 | { 64 | "color": "rgb(220, 218, 245)", 65 | "count": 12, 66 | "confidence": "medium", 67 | "sources": [] 68 | }, 69 | { 70 | "color": "rgb(245, 239, 241)", 71 | "count": 6, 72 | "confidence": "medium", 73 | "sources": [] 74 | }, 75 | { 76 | "color": "rgb(255, 255, 255)", 77 | "count": 5, 78 | "confidence": "medium", 79 | "sources": [ 80 | "mio-button" 81 | ] 82 | }, 83 | { 84 | "color": "rgb(241, 211, 249)", 85 | "count": 2, 86 | "confidence": "low", 87 | "sources": [] 88 | }, 89 | { 90 | "color": "rgb(33, 24, 43)", 91 | "count": 2, 92 | "confidence": "low", 93 | "sources": [] 94 | }, 95 | { 96 | "color": "rgb(254, 251, 255)", 97 | "count": 1, 98 | "confidence": "low", 99 | "sources": [] 100 | }, 101 | { 102 | "color": "rgb(242, 236, 238)", 103 | "count": 1, 104 | "confidence": "low", 105 | "sources": [] 106 | }, 107 | { 108 | "color": "rgb(48, 48, 48)", 109 | "count": 1, 110 | "confidence": "low", 111 | "sources": [] 112 | }, 113 | { 114 | "color": "rgb(159, 134, 255)", 115 | "count": 1, 116 | "confidence": "low", 117 | "sources": [] 118 | } 119 | ], 120 | "cssVariables": { 121 | "--mio-theme-color-white": "#fff", 122 | "--mio-theme-color-scrim-video-control-pressed": "rgb(255 255 255 / 24%)", 123 | "--mio-theme-color-scrim-video-control": "rgb(255 255 255 / 12%)", 124 | "--mio-theme-text-font-family": "\"Google Sans Text\", sans-serif", 125 | "--mio-theme-color-on-surface": "#1f1f1f", 126 | "--mio-theme-color-scrim-video-container": "rgb(31 31 31 / 64%)" 127 | } 128 | }, 129 | "typography": { 130 | "styles": [ 131 | { 132 | "fontFamily": "\"Google Sans Text\", sans-serif", 133 | "fontSize": "16px", 134 | "fontSizeRem": "1.00rem", 135 | "fontWeight": "500", 136 | "lineHeight": "24px", 137 | "contexts": [ 138 | "a" 139 | ], 140 | "confidence": "medium" 141 | }, 142 | { 143 | "fontFamily": "\"Google Sans Text\", sans-serif", 144 | "fontSize": "16px", 145 | "fontSizeRem": "1.00rem", 146 | "fontWeight": "400", 147 | "lineHeight": "24px", 148 | "contexts": [ 149 | "div", 150 | "p" 151 | ], 152 | "confidence": "medium" 153 | }, 154 | { 155 | "fontFamily": "\"Google Sans\", sans-serif", 156 | "fontSize": "96px", 157 | "fontSizeRem": "6.00rem", 158 | "fontWeight": "475", 159 | "lineHeight": "96px", 160 | "contexts": [ 161 | "h1" 162 | ], 163 | "confidence": "high" 164 | }, 165 | { 166 | "fontFamily": "\"Google Sans\", sans-serif", 167 | "fontSize": "24px", 168 | "fontSizeRem": "1.50rem", 169 | "fontWeight": "475", 170 | "lineHeight": "32px", 171 | "contexts": [ 172 | "button", 173 | "span" 174 | ], 175 | "confidence": "medium" 176 | }, 177 | { 178 | "fontFamily": "Arial", 179 | "fontSize": "13.3333px", 180 | "fontSizeRem": "0.83rem", 181 | "fontWeight": "400", 182 | "lineHeight": "40px", 183 | "contexts": [ 184 | "button" 185 | ], 186 | "confidence": "medium" 187 | }, 188 | { 189 | "fontFamily": "\"Google Sans\", sans-serif", 190 | "fontSize": "57px", 191 | "fontSizeRem": "3.56rem", 192 | "fontWeight": "475", 193 | "lineHeight": "64px", 194 | "contexts": [ 195 | "h2" 196 | ], 197 | "confidence": "high" 198 | }, 199 | { 200 | "fontFamily": "\"Google Sans Text\", sans-serif", 201 | "fontSize": "22px", 202 | "fontSizeRem": "1.38rem", 203 | "fontWeight": "400", 204 | "lineHeight": "30px", 205 | "contexts": [ 206 | "p", 207 | "a" 208 | ], 209 | "confidence": "medium" 210 | }, 211 | { 212 | "fontFamily": "\"Google Sans\", sans-serif", 213 | "fontSize": "45px", 214 | "fontSizeRem": "2.81rem", 215 | "fontWeight": "475", 216 | "lineHeight": "52px", 217 | "contexts": [ 218 | "h2" 219 | ], 220 | "confidence": "high" 221 | }, 222 | { 223 | "fontFamily": "\"Google Sans Text\", sans-serif", 224 | "fontSize": "14px", 225 | "fontSizeRem": "0.88rem", 226 | "fontWeight": "500", 227 | "lineHeight": "20px", 228 | "contexts": [ 229 | "h3" 230 | ], 231 | "confidence": "high" 232 | }, 233 | { 234 | "fontFamily": "\"Google Sans Text\", sans-serif", 235 | "fontSize": "14px", 236 | "fontSizeRem": "0.88rem", 237 | "fontWeight": "400", 238 | "lineHeight": "20px", 239 | "contexts": [ 240 | "p", 241 | "a" 242 | ], 243 | "confidence": "medium" 244 | } 245 | ], 246 | "sources": { 247 | "googleFonts": [], 248 | "adobeFonts": false, 249 | "customFonts": [] 250 | } 251 | }, 252 | "spacing": { 253 | "scaleType": "8px", 254 | "commonValues": [ 255 | { 256 | "px": "24px", 257 | "rem": "1.50rem", 258 | "count": 49 259 | }, 260 | { 261 | "px": "1px", 262 | "rem": "0.06rem", 263 | "count": 46 264 | }, 265 | { 266 | "px": "2px", 267 | "rem": "0.13rem", 268 | "count": 16 269 | }, 270 | { 271 | "px": "4px", 272 | "rem": "0.25rem", 273 | "count": 16 274 | }, 275 | { 276 | "px": "8px", 277 | "rem": "0.50rem", 278 | "count": 11 279 | }, 280 | { 281 | "px": "96px", 282 | "rem": "6.00rem", 283 | "count": 9 284 | }, 285 | { 286 | "px": "14px", 287 | "rem": "0.88rem", 288 | "count": 7 289 | }, 290 | { 291 | "px": "32px", 292 | "rem": "2.00rem", 293 | "count": 4 294 | }, 295 | { 296 | "px": "16px", 297 | "rem": "1.00rem", 298 | "count": 3 299 | }, 300 | { 301 | "px": "64px", 302 | "rem": "4.00rem", 303 | "count": 3 304 | }, 305 | { 306 | "px": "56px", 307 | "rem": "3.50rem", 308 | "count": 2 309 | }, 310 | { 311 | "px": "20px", 312 | "rem": "1.25rem", 313 | "count": 1 314 | }, 315 | { 316 | "px": "120px", 317 | "rem": "7.50rem", 318 | "count": 1 319 | } 320 | ] 321 | }, 322 | "borderRadius": { 323 | "values": [ 324 | { 325 | "value": "24px", 326 | "count": 31, 327 | "confidence": "high" 328 | }, 329 | { 330 | "value": "4px", 331 | "count": 30, 332 | "confidence": "high" 333 | }, 334 | { 335 | "value": "50%", 336 | "count": 19, 337 | "confidence": "high" 338 | }, 339 | { 340 | "value": "16px", 341 | "count": 10, 342 | "confidence": "medium" 343 | }, 344 | { 345 | "value": "48px", 346 | "count": 3, 347 | "confidence": "low" 348 | }, 349 | { 350 | "value": "32px", 351 | "count": 2, 352 | "confidence": "low" 353 | }, 354 | { 355 | "value": "8px", 356 | "count": 1, 357 | "confidence": "low" 358 | }, 359 | { 360 | "value": "22px", 361 | "count": 1, 362 | "confidence": "low" 363 | } 364 | ] 365 | }, 366 | "shadows": [ 367 | { 368 | "shadow": "rgba(0, 0, 0, 0.3) 0px 1px 2px 0px, rgba(0, 0, 0, 0.15) 0px 1px 3px 1px", 369 | "count": 1, 370 | "confidence": "low" 371 | } 372 | ], 373 | "components": { 374 | "buttons": [ 375 | { 376 | "backgroundColor": "rgba(0, 0, 0, 0)", 377 | "color": "rgb(31, 31, 31)", 378 | "padding": "0px", 379 | "borderRadius": "0px", 380 | "border": "0px none rgb(31, 31, 31)", 381 | "fontWeight": "400", 382 | "fontSize": "16px", 383 | "classes": "navbar-toggle-buttons", 384 | "confidence": "medium" 385 | }, 386 | { 387 | "backgroundColor": "rgb(100, 66, 214)", 388 | "color": "rgb(255, 255, 255)", 389 | "padding": "0px 48px", 390 | "borderRadius": "48px", 391 | "border": "0px none rgb(255, 255, 255)", 392 | "fontWeight": "475", 393 | "fontSize": "24px", 394 | "classes": "mio-button", 395 | "confidence": "high" 396 | }, 397 | { 398 | "backgroundColor": "rgb(241, 211, 249)", 399 | "color": "rgb(77, 66, 86)", 400 | "padding": "0px", 401 | "borderRadius": "50%", 402 | "border": "0px none rgb(77, 66, 86)", 403 | "fontWeight": "400", 404 | "fontSize": "13.3333px", 405 | "classes": "ally-container ally-container-align ng-star-inserted", 406 | "confidence": "high" 407 | } 408 | ], 409 | "inputs": [] 410 | }, 411 | "breakpoints": [ 412 | { 413 | "px": "600px" 414 | }, 415 | { 416 | "px": "601px" 417 | }, 418 | { 419 | "px": "960px" 420 | }, 421 | { 422 | "px": "961px" 423 | }, 424 | { 425 | "px": "1294px" 426 | }, 427 | { 428 | "px": "1295px" 429 | } 430 | ], 431 | "iconSystem": [], 432 | "frameworks": [ 433 | { 434 | "name": "Bootstrap", 435 | "confidence": "high", 436 | "evidence": "class patterns" 437 | } 438 | ] 439 | } 440 | -------------------------------------------------------------------------------- /examples/stripe.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://stripe.com/en-fi", 3 | "extractedAt": "2025-11-22T15:29:00.999Z", 4 | "logo": { 5 | "source": "svg", 6 | "width": 60, 7 | "height": 25 8 | }, 9 | "colors": { 10 | "semantic": {}, 11 | "palette": [ 12 | { 13 | "color": "rgb(66, 84, 102)", 14 | "count": 3685, 15 | "confidence": "high", 16 | "sources": [ 17 | "\n", 18 | "siteheader__stickycontainer", 19 | "siteheader__stickyshadow", 20 | "siteheader__guidescontainer", 21 | "siteheader__container", 22 | "siteheader__navcontainer", 23 | "siteheader__logo", 24 | "siteheader__headernav", 25 | "siteheadernav", 26 | "siteheadernav__list", 27 | "siteheader__ctanav", 28 | "siteheader__ctanavcontainer", 29 | "dashboardloginlink__container", 30 | "siteheader__menunav", 31 | "mobilemenu__header", 32 | "mobilemenu__logo", 33 | "siteheader__menucontainer", 34 | "siteheaderarrow", 35 | "siteheader__menushadowcontainer", 36 | "sitedevelopersnav__header", 37 | "homepageherogradient", 38 | "heroemailinputmobilectas", 39 | "heroemailinputmobilectas__ctas", 40 | "heroemailinputmobilectas__emailinput", 41 | "heroemailinput", 42 | "heroemailinput__form", 43 | "homepagedashboardgraphic", 44 | "homepagedashboardgraphic__header", 45 | "checkoutphonegraphic__buttonscontainer", 46 | "applepaysheet__row", 47 | "copy__header", 48 | "homepagefrontdoorpaymentsgraphic__buttonpricetrackcontainer", 49 | "homepagefrontdoorpaymentsgraphic__buttonpricetrack", 50 | "accentedcardcarouselitem__userlogogrid", 51 | "customerscasestudycarouselnavgroup__logogrid", 52 | "[object", 53 | "domgraphic", 54 | "paymentlinksfeaturegraphic__layout", 55 | "paymentlinksfeaturegraphic__chat", 56 | "paymentlinksfeaturegraphic__qrcodetext", 57 | "paymentlinksfeaturegraphic__qrcode", 58 | "paymentlinksfeaturegraphic__qrcodegraphic", 59 | "paymentlinksfeaturegraphic__qrcodeinstruction", 60 | "brandmodal__track", 61 | "brandmodal__panel", 62 | "brandmodal__section", 63 | "brandmodal__body", 64 | "brandmodal__setting", 65 | "brandmodal__icon", 66 | "brandmodal__logo", 67 | "brandmodal__color", 68 | "brandmodal__colorswatch", 69 | "brandmodal__colorhex" 70 | ] 71 | }, 72 | { 73 | "color": "rgb(10, 37, 64)", 74 | "count": 2294, 75 | "confidence": "high", 76 | "sources": [ 77 | "sitenavitem__link", 78 | "siteproductsnavsubitem__link", 79 | "\n", 80 | "checkoutphonegraphic__button", 81 | "paymentlinksfeaturegraphic__chatmessage", 82 | "paymentlinksfeaturegraphic__qrcodeprice", 83 | "paymentlinksfeaturegraphic__qrcodepricelabel", 84 | "brandmodal__title", 85 | "brandmodal__label", 86 | "sitefootersection__logo", 87 | "link", 88 | "ctabutton\n" 89 | ] 90 | }, 91 | { 92 | "color": "rgb(99, 91, 255)", 93 | "count": 1565, 94 | "confidence": "high", 95 | "sources": [ 96 | "mobilemenu__backbutton", 97 | "mobilemenu__backbuttontext", 98 | "\n", 99 | "homepagedashboardgraphic__copy--small", 100 | "homepagefrontdooricon__iconlogo", 101 | "homepagefrontdoorbillinggraphictier__button", 102 | "copy__header" 103 | ] 104 | }, 105 | { 106 | "color": "rgb(63, 75, 102)", 107 | "count": 470, 108 | "confidence": "high", 109 | "sources": [ 110 | "sitemobilemenunavitem__link", 111 | "\n", 112 | "homepagefrontdoorbillinggraphic__header", 113 | "homepagefrontdoorbillinggraphic__logocontainer", 114 | "homepagefrontdoorbillinggraphic__logotrack", 115 | "homepagefrontdoorbillinggraphiclogo", 116 | "homepagefrontdoorbillinggraphiclogo__icon", 117 | "homepagefrontdoorbillinggraphiclogo__name" 118 | ] 119 | }, 120 | { 121 | "color": "rgb(255, 255, 255)", 122 | "count": 389, 123 | "confidence": "high", 124 | "sources": [ 125 | "\n", 126 | "siteheadernavitem__linktext", 127 | "siteheadernavitem__linkcaretcontainer", 128 | "menubutton", 129 | "mobilemenu__header", 130 | "siteheaderarrow", 131 | "checkoutphonegraphic__button", 132 | "homepagefrontdoorpaymentsgraphic__buttonprice", 133 | "homepagefrontdoorbillinggraphictier__button", 134 | "paymentlinksfeaturegraphic__qrcodegraphic", 135 | "ctabutton\n" 136 | ] 137 | }, 138 | { 139 | "color": "rgb(0, 0, 0)", 140 | "count": 345, 141 | "confidence": "high", 142 | "sources": [ 143 | "mobilemenu__closebutton", 144 | "accentedcardcarousel__button", 145 | "customerscasestudycarouselnavitem__button" 146 | ] 147 | }, 148 | { 149 | "color": "rgb(114, 127, 150)", 150 | "count": 151, 151 | "confidence": "high", 152 | "sources": [ 153 | "\n" 154 | ] 155 | }, 156 | { 157 | "color": "rgb(173, 189, 204)", 158 | "count": 133, 159 | "confidence": "high", 160 | "sources": [ 161 | "copy__header" 162 | ] 163 | }, 164 | { 165 | "color": "rgb(46, 58, 85)", 166 | "count": 132, 167 | "confidence": "high", 168 | "sources": [] 169 | }, 170 | { 171 | "color": "rgb(246, 249, 252)", 172 | "count": 90, 173 | "confidence": "high", 174 | "sources": [ 175 | "\n" 176 | ] 177 | }, 178 | { 179 | "color": "rgb(98, 120, 141)", 180 | "count": 40, 181 | "confidence": "high", 182 | "sources": [] 183 | }, 184 | { 185 | "color": "rgb(11, 37, 64)", 186 | "count": 37, 187 | "confidence": "high", 188 | "sources": [] 189 | }, 190 | { 191 | "color": "rgb(246, 249, 251)", 192 | "count": 29, 193 | "confidence": "high", 194 | "sources": [ 195 | "\n" 196 | ] 197 | }, 198 | { 199 | "color": "rgb(0, 212, 255)", 200 | "count": 24, 201 | "confidence": "high", 202 | "sources": [ 203 | "\n" 204 | ] 205 | }, 206 | { 207 | "color": "rgb(0, 0, 238)", 208 | "count": 23, 209 | "confidence": "high", 210 | "sources": [ 211 | "siteheader__logolink", 212 | "siteproductsnavcollapseditem__link", 213 | "sitefootersection__logolink" 214 | ] 215 | }, 216 | { 217 | "color": "rgba(66, 71, 112, 0.06)", 218 | "count": 18, 219 | "confidence": "medium", 220 | "sources": [] 221 | }, 222 | { 223 | "color": "rgba(21, 190, 83, 0.3)", 224 | "count": 12, 225 | "confidence": "medium", 226 | "sources": [] 227 | }, 228 | { 229 | "color": "rgb(231, 236, 241)", 230 | "count": 12, 231 | "confidence": "medium", 232 | "sources": [ 233 | "paymentlinksfeaturegraphic__chatmessage" 234 | ] 235 | }, 236 | { 237 | "color": "rgb(21, 190, 83)", 238 | "count": 11, 239 | "confidence": "medium", 240 | "sources": [ 241 | "homepagefrontdoorbillinggraphictier__button" 242 | ] 243 | }, 244 | { 245 | "color": "rgb(85, 113, 141)", 246 | "count": 11, 247 | "confidence": "medium", 248 | "sources": [] 249 | }, 250 | { 251 | "color": "rgb(153, 102, 255)", 252 | "count": 10, 253 | "confidence": "medium", 254 | "sources": [ 255 | "homepagefrontdoorbillinggraphictier__button" 256 | ] 257 | }, 258 | { 259 | "color": "rgba(99, 91, 255, 0.3)", 260 | "count": 10, 261 | "confidence": "medium", 262 | "sources": [] 263 | }, 264 | { 265 | "color": "rgb(79, 91, 118)", 266 | "count": 9, 267 | "confidence": "medium", 268 | "sources": [] 269 | }, 270 | { 271 | "color": "rgb(102, 120, 143)", 272 | "count": 7, 273 | "confidence": "medium", 274 | "sources": [] 275 | }, 276 | { 277 | "color": "rgba(255, 255, 255, 0.8)", 278 | "count": 5, 279 | "confidence": "low", 280 | "sources": [] 281 | }, 282 | { 283 | "color": "rgb(239, 168, 46)", 284 | "count": 5, 285 | "confidence": "low", 286 | "sources": [] 287 | }, 288 | { 289 | "color": "rgba(66, 71, 112, 0.3)", 290 | "count": 4, 291 | "confidence": "low", 292 | "sources": [] 293 | }, 294 | { 295 | "color": "rgb(66, 176, 213)", 296 | "count": 4, 297 | "confidence": "low", 298 | "sources": [] 299 | }, 300 | { 301 | "color": "rgb(197, 70, 71)", 302 | "count": 4, 303 | "confidence": "low", 304 | "sources": [] 305 | }, 306 | { 307 | "color": "rgb(128, 233, 255)", 308 | "count": 4, 309 | "confidence": "low", 310 | "sources": [] 311 | }, 312 | { 313 | "color": "rgb(122, 115, 255)", 314 | "count": 4, 315 | "confidence": "low", 316 | "sources": [] 317 | }, 318 | { 319 | "color": "rgb(0, 72, 229)", 320 | "count": 4, 321 | "confidence": "low", 322 | "sources": [] 323 | }, 324 | { 325 | "color": "rgb(128, 149, 255)", 326 | "count": 4, 327 | "confidence": "low", 328 | "sources": [] 329 | }, 330 | { 331 | "color": "rgb(0, 102, 177)", 332 | "count": 3, 333 | "confidence": "low", 334 | "sources": [] 335 | }, 336 | { 337 | "color": "rgba(1, 1, 1, 0.3)", 338 | "count": 3, 339 | "confidence": "low", 340 | "sources": [] 341 | }, 342 | { 343 | "color": "rgb(54, 70, 87)", 344 | "count": 3, 345 | "confidence": "low", 346 | "sources": [] 347 | }, 348 | { 349 | "color": "rgb(239, 243, 249)", 350 | "count": 2, 351 | "confidence": "low", 352 | "sources": [] 353 | }, 354 | { 355 | "color": "rgb(58, 58, 58)", 356 | "count": 2, 357 | "confidence": "low", 358 | "sources": [ 359 | "\n" 360 | ] 361 | }, 362 | { 363 | "color": "rgb(65, 69, 82)", 364 | "count": 2, 365 | "confidence": "low", 366 | "sources": [] 367 | }, 368 | { 369 | "color": "rgb(0, 115, 230)", 370 | "count": 2, 371 | "confidence": "low", 372 | "sources": [] 373 | }, 374 | { 375 | "color": "rgb(0, 196, 196)", 376 | "count": 2, 377 | "confidence": "low", 378 | "sources": [] 379 | }, 380 | { 381 | "color": "rgb(12, 46, 78)", 382 | "count": 2, 383 | "confidence": "low", 384 | "sources": [] 385 | }, 386 | { 387 | "color": "rgb(6, 24, 44)", 388 | "count": 2, 389 | "confidence": "low", 390 | "sources": [] 391 | }, 392 | { 393 | "color": "rgba(255, 255, 255, 0.2)", 394 | "count": 1, 395 | "confidence": "low", 396 | "sources": [ 397 | "menubutton" 398 | ] 399 | }, 400 | { 401 | "color": "rgb(189, 198, 210)", 402 | "count": 1, 403 | "confidence": "low", 404 | "sources": [ 405 | "homepageheroheader__title" 406 | ] 407 | }, 408 | { 409 | "color": "rgb(235, 238, 241)", 410 | "count": 1, 411 | "confidence": "low", 412 | "sources": [] 413 | }, 414 | { 415 | "color": "rgb(11, 48, 85)", 416 | "count": 1, 417 | "confidence": "low", 418 | "sources": [] 419 | }, 420 | { 421 | "color": "rgb(42, 105, 254)", 422 | "count": 1, 423 | "confidence": "low", 424 | "sources": [ 425 | "applepaysheet__cancelbutton" 426 | ] 427 | }, 428 | { 429 | "color": "rgb(196, 204, 216)", 430 | "count": 1, 431 | "confidence": "low", 432 | "sources": [] 433 | }, 434 | { 435 | "color": "rgb(0, 67, 119)", 436 | "count": 1, 437 | "confidence": "low", 438 | "sources": [] 439 | }, 440 | { 441 | "color": "rgba(1, 150, 136, 0.2)", 442 | "count": 1, 443 | "confidence": "low", 444 | "sources": [ 445 | "paymentlinksfeaturegraphic__chatmessage" 446 | ] 447 | }, 448 | { 449 | "color": "rgb(3, 101, 92)", 450 | "count": 1, 451 | "confidence": "low", 452 | "sources": [ 453 | "paymentlinksfeaturegraphic__chatmessage" 454 | ] 455 | }, 456 | { 457 | "color": "rgb(1, 150, 136)", 458 | "count": 1, 459 | "confidence": "low", 460 | "sources": [ 461 | "paymentlinksfeaturegraphic__chatmessagelink" 462 | ] 463 | }, 464 | { 465 | "color": "rgb(26, 52, 78)", 466 | "count": 1, 467 | "confidence": "low", 468 | "sources": [ 469 | "paymentlinksfeaturegraphic__qrcode" 470 | ] 471 | }, 472 | { 473 | "color": "rgb(25, 67, 105)", 474 | "count": 1, 475 | "confidence": "low", 476 | "sources": [] 477 | }, 478 | { 479 | "color": "rgb(245, 240, 234)", 480 | "count": 1, 481 | "confidence": "low", 482 | "sources": [ 483 | "brandmodal__colorswatch" 484 | ] 485 | }, 486 | { 487 | "color": "rgb(38, 38, 39)", 488 | "count": 1, 489 | "confidence": "low", 490 | "sources": [ 491 | "brandmodal__colorswatch" 492 | ] 493 | } 494 | ], 495 | "cssVariables": { 496 | "--cardShadowLargeInset": "inset 0 30px 60px -12px rgba(50,50,93,0.25),inset 0 18px 36px -18px rgba(0,0,0,0.3)", 497 | "--gutterWidth": "calc(calc(100vw - 0px)/2 - 1080px/2)", 498 | "--columnMaxWidth": "calc(1080px*0.25)", 499 | "--viewportScale": "calc(calc(100vw - 0px)/1112)", 500 | "--fixedNavScrollMargin": "calc(60px + 48px)", 501 | "--copyMaxWidth": "calc(calc(1080px*0.25)*3)", 502 | "--columnWidth": "calc(1080px/4)", 503 | "--windowWidth": "calc(100vw - 0px)" 504 | } 505 | }, 506 | "typography": { 507 | "styles": [ 508 | { 509 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 510 | "fontSize": "32px", 511 | "fontSizeRem": "2.00rem", 512 | "fontWeight": "700", 513 | "lineHeight": "normal", 514 | "contexts": [ 515 | "h1", 516 | "a" 517 | ], 518 | "confidence": "high" 519 | }, 520 | { 521 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 522 | "fontSize": "15px", 523 | "fontSizeRem": "0.94rem", 524 | "fontWeight": "500", 525 | "lineHeight": "24px", 526 | "contexts": [ 527 | "button", 528 | "a" 529 | ], 530 | "confidence": "medium" 531 | }, 532 | { 533 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 534 | "fontSize": "15px", 535 | "fontSizeRem": "0.94rem", 536 | "fontWeight": "425", 537 | "lineHeight": "24px", 538 | "contexts": [ 539 | "a", 540 | "h1" 541 | ], 542 | "confidence": "medium" 543 | }, 544 | { 545 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 546 | "fontSize": "16px", 547 | "fontSizeRem": "1.00rem", 548 | "fontWeight": "300", 549 | "lineHeight": "normal", 550 | "contexts": [ 551 | "a", 552 | "p" 553 | ], 554 | "confidence": "medium" 555 | }, 556 | { 557 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 558 | "fontSize": "16px", 559 | "fontSizeRem": "1.00rem", 560 | "fontWeight": "425", 561 | "lineHeight": "24px", 562 | "contexts": [ 563 | "button", 564 | "a" 565 | ], 566 | "confidence": "medium" 567 | }, 568 | { 569 | "fontFamily": "Arial", 570 | "fontSize": "13.3333px", 571 | "fontSizeRem": "0.83rem", 572 | "fontWeight": "400", 573 | "lineHeight": "normal", 574 | "contexts": [ 575 | "button" 576 | ], 577 | "confidence": "medium" 578 | }, 579 | { 580 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 581 | "fontSize": "18px", 582 | "fontSizeRem": "1.13rem", 583 | "fontWeight": "500", 584 | "lineHeight": "22.86px", 585 | "contexts": [ 586 | "button", 587 | "a", 588 | "h2" 589 | ], 590 | "confidence": "medium" 591 | }, 592 | { 593 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 594 | "fontSize": "13px", 595 | "fontSizeRem": "0.81rem", 596 | "fontWeight": "425", 597 | "lineHeight": "23.92px", 598 | "contexts": [ 599 | "h1", 600 | "h2" 601 | ], 602 | "confidence": "high" 603 | }, 604 | { 605 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 606 | "fontSize": "12px", 607 | "fontSizeRem": "0.75rem", 608 | "fontWeight": "425", 609 | "lineHeight": "13.2px", 610 | "contexts": [ 611 | "a" 612 | ], 613 | "confidence": "medium" 614 | }, 615 | { 616 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 617 | "fontSize": "14px", 618 | "fontSizeRem": "0.88rem", 619 | "fontWeight": "300", 620 | "lineHeight": "21px", 621 | "contexts": [ 622 | "p", 623 | "a" 624 | ], 625 | "confidence": "medium" 626 | }, 627 | { 628 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 629 | "fontSize": "94px", 630 | "fontSizeRem": "5.88rem", 631 | "fontWeight": "500", 632 | "lineHeight": "97.76px", 633 | "contexts": [ 634 | "h1" 635 | ], 636 | "confidence": "high" 637 | }, 638 | { 639 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 640 | "fontSize": "56px", 641 | "fontSizeRem": "3.50rem", 642 | "fontWeight": "500", 643 | "lineHeight": "68px", 644 | "contexts": [ 645 | "h1" 646 | ], 647 | "confidence": "high" 648 | }, 649 | { 650 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 651 | "fontSize": "18px", 652 | "fontSizeRem": "1.13rem", 653 | "fontWeight": "300", 654 | "lineHeight": "28px", 655 | "contexts": [ 656 | "p" 657 | ], 658 | "confidence": "medium" 659 | }, 660 | { 661 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 662 | "fontSize": "38px", 663 | "fontSizeRem": "2.38rem", 664 | "fontWeight": "500", 665 | "lineHeight": "48px", 666 | "contexts": [ 667 | "h1" 668 | ], 669 | "confidence": "high" 670 | }, 671 | { 672 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 673 | "fontSize": "24px", 674 | "fontSizeRem": "1.50rem", 675 | "fontWeight": "500", 676 | "lineHeight": "32px", 677 | "contexts": [ 678 | "h1" 679 | ], 680 | "confidence": "high" 681 | }, 682 | { 683 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 684 | "fontSize": "28px", 685 | "fontSizeRem": "1.75rem", 686 | "fontWeight": "425", 687 | "lineHeight": "36px", 688 | "contexts": [ 689 | "h1" 690 | ], 691 | "confidence": "high" 692 | }, 693 | { 694 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 695 | "fontSize": "18px", 696 | "fontSizeRem": "1.13rem", 697 | "fontWeight": "425", 698 | "lineHeight": "28px", 699 | "contexts": [ 700 | "a" 701 | ], 702 | "confidence": "medium" 703 | }, 704 | { 705 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 706 | "fontSize": "26px", 707 | "fontSizeRem": "1.63rem", 708 | "fontWeight": "500", 709 | "lineHeight": "36px", 710 | "contexts": [ 711 | "h1" 712 | ], 713 | "confidence": "high" 714 | }, 715 | { 716 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 717 | "fontSize": "10px", 718 | "fontSizeRem": "0.63rem", 719 | "fontWeight": "300", 720 | "lineHeight": "11.9px", 721 | "contexts": [ 722 | "h3" 723 | ], 724 | "confidence": "high" 725 | }, 726 | { 727 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 728 | "fontSize": "15px", 729 | "fontSizeRem": "0.94rem", 730 | "fontWeight": "300", 731 | "lineHeight": "24px", 732 | "contexts": [ 733 | "button", 734 | "a" 735 | ], 736 | "confidence": "medium" 737 | }, 738 | { 739 | "fontFamily": "sohne-var, \"Helvetica Neue\", Arial, sans-serif", 740 | "fontSize": "12px", 741 | "fontSizeRem": "0.75rem", 742 | "fontWeight": "300", 743 | "lineHeight": "16.992px", 744 | "contexts": [ 745 | "a" 746 | ], 747 | "confidence": "medium" 748 | } 749 | ], 750 | "sources": { 751 | "googleFonts": [], 752 | "adobeFonts": false, 753 | "customFonts": [] 754 | } 755 | }, 756 | "spacing": { 757 | "scaleType": "8px", 758 | "commonValues": [ 759 | { 760 | "px": "4px", 761 | "rem": "0.25rem", 762 | "count": 291 763 | }, 764 | { 765 | "px": "12px", 766 | "rem": "0.75rem", 767 | "count": 90 768 | }, 769 | { 770 | "px": "2px", 771 | "rem": "0.13rem", 772 | "count": 81 773 | }, 774 | { 775 | "px": "10px", 776 | "rem": "0.63rem", 777 | "count": 77 778 | }, 779 | { 780 | "px": "8px", 781 | "rem": "0.50rem", 782 | "count": 68 783 | }, 784 | { 785 | "px": "24px", 786 | "rem": "1.50rem", 787 | "count": 62 788 | }, 789 | { 790 | "px": "3px", 791 | "rem": "0.19rem", 792 | "count": 55 793 | }, 794 | { 795 | "px": "5px", 796 | "rem": "0.31rem", 797 | "count": 46 798 | }, 799 | { 800 | "px": "6px", 801 | "rem": "0.38rem", 802 | "count": 45 803 | }, 804 | { 805 | "px": "16px", 806 | "rem": "1.00rem", 807 | "count": 39 808 | }, 809 | { 810 | "px": "32px", 811 | "rem": "2.00rem", 812 | "count": 31 813 | }, 814 | { 815 | "px": "7px", 816 | "rem": "0.44rem", 817 | "count": 28 818 | }, 819 | { 820 | "px": "20px", 821 | "rem": "1.25rem", 822 | "count": 28 823 | }, 824 | { 825 | "px": "13px", 826 | "rem": "0.81rem", 827 | "count": 15 828 | }, 829 | { 830 | "px": "1px", 831 | "rem": "0.06rem", 832 | "count": 14 833 | }, 834 | { 835 | "px": "21px", 836 | "rem": "1.31rem", 837 | "count": 14 838 | }, 839 | { 840 | "px": "28px", 841 | "rem": "1.75rem", 842 | "count": 13 843 | }, 844 | { 845 | "px": "22px", 846 | "rem": "1.38rem", 847 | "count": 13 848 | }, 849 | { 850 | "px": "128px", 851 | "rem": "8.00rem", 852 | "count": 12 853 | }, 854 | { 855 | "px": "18px", 856 | "rem": "1.13rem", 857 | "count": 8 858 | } 859 | ] 860 | }, 861 | "borderRadius": { 862 | "values": [ 863 | { 864 | "value": "8px", 865 | "count": 207, 866 | "confidence": "high" 867 | }, 868 | { 869 | "value": "4px", 870 | "count": 129, 871 | "confidence": "high" 872 | }, 873 | { 874 | "value": "16.5px", 875 | "count": 27, 876 | "confidence": "high" 877 | }, 878 | { 879 | "value": "9999px", 880 | "count": 18, 881 | "confidence": "high" 882 | }, 883 | { 884 | "value": "14px", 885 | "count": 14, 886 | "confidence": "high" 887 | }, 888 | { 889 | "value": "0px 0px 4px 4px", 890 | "count": 12, 891 | "confidence": "high" 892 | }, 893 | { 894 | "value": "1.5px 1.5px 0px 0px", 895 | "count": 7, 896 | "confidence": "medium" 897 | }, 898 | { 899 | "value": "42px", 900 | "count": 4, 901 | "confidence": "medium" 902 | }, 903 | { 904 | "value": "1px", 905 | "count": 4, 906 | "confidence": "medium" 907 | }, 908 | { 909 | "value": "6px", 910 | "count": 4, 911 | "confidence": "medium" 912 | }, 913 | { 914 | "value": "3px", 915 | "count": 4, 916 | "confidence": "medium" 917 | }, 918 | { 919 | "value": "2.54px", 920 | "count": 3, 921 | "confidence": "low" 922 | }, 923 | { 924 | "value": "50%", 925 | "count": 2, 926 | "confidence": "low" 927 | }, 928 | { 929 | "value": "2px", 930 | "count": 2, 931 | "confidence": "low" 932 | }, 933 | { 934 | "value": "2px 0px 0px 2px", 935 | "count": 2, 936 | "confidence": "low" 937 | }, 938 | { 939 | "value": "16px", 940 | "count": 1, 941 | "confidence": "low" 942 | }, 943 | { 944 | "value": "4px 4px 0px 0px", 945 | "count": 1, 946 | "confidence": "low" 947 | }, 948 | { 949 | "value": "3px 0px 0px", 950 | "count": 1, 951 | "confidence": "low" 952 | }, 953 | { 954 | "value": "32px", 955 | "count": 1, 956 | "confidence": "low" 957 | }, 958 | { 959 | "value": "3.38px", 960 | "count": 1, 961 | "confidence": "low" 962 | }, 963 | { 964 | "value": "36px", 965 | "count": 1, 966 | "confidence": "low" 967 | }, 968 | { 969 | "value": "29px", 970 | "count": 1, 971 | "confidence": "low" 972 | }, 973 | { 974 | "value": "28px", 975 | "count": 1, 976 | "confidence": "low" 977 | }, 978 | { 979 | "value": "8px 8px 30px 30px", 980 | "count": 1, 981 | "confidence": "low" 982 | }, 983 | { 984 | "value": "6px 6px 0px 0px", 985 | "count": 1, 986 | "confidence": "low" 987 | }, 988 | { 989 | "value": "0px 0px 0px 6px", 990 | "count": 1, 991 | "confidence": "low" 992 | }, 993 | { 994 | "value": "0px 0px 6px", 995 | "count": 1, 996 | "confidence": "low" 997 | }, 998 | { 999 | "value": "8px 8px 0px 0px", 1000 | "count": 1, 1001 | "confidence": "low" 1002 | } 1003 | ] 1004 | }, 1005 | "shadows": [ 1006 | { 1007 | "shadow": "rgba(50, 50, 93, 0.25) 0px 12.6px 25.2px -11.5733px, rgba(0, 0, 0, 0.1) 0px 7.56px 15.12px -7.56px", 1008 | "count": 64, 1009 | "confidence": "high" 1010 | }, 1011 | { 1012 | "shadow": "rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px", 1013 | "count": 21, 1014 | "confidence": "high" 1015 | }, 1016 | { 1017 | "shadow": "rgba(50, 50, 93, 0.25) 0px 6.3px 11.5px -3.5px, rgba(0, 0, 0, 0.1) 0px 3.8px 7.5px -3.7px", 1018 | "count": 12, 1019 | "confidence": "high" 1020 | }, 1021 | { 1022 | "shadow": "rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px", 1023 | "count": 9, 1024 | "confidence": "high" 1025 | }, 1026 | { 1027 | "shadow": "rgba(50, 50, 93, 0.25) 0px 6px 12px -2px, rgba(0, 0, 0, 0.3) 0px 3px 7px -3px", 1028 | "count": 8, 1029 | "confidence": "high" 1030 | }, 1031 | { 1032 | "shadow": "rgba(0, 0, 0, 0.06) 0px 100px 60px -40px, rgba(50, 50, 93, 0.15) 0px 60px 100px 0px", 1033 | "count": 7, 1034 | "confidence": "high" 1035 | }, 1036 | { 1037 | "shadow": "rgba(0, 0, 0, 0.1) 0px 18px 36px -18px, rgba(50, 50, 93, 0.25) 0px 30px 45px -30px", 1038 | "count": 7, 1039 | "confidence": "high" 1040 | }, 1041 | { 1042 | "shadow": "rgba(50, 50, 93, 0.07) 0px 0px 0px 1px, rgba(50, 50, 93, 0.12) 0px 2px 3px -1px, rgba(0, 0, 0, 0.12) 0px 1px 3px -1px", 1043 | "count": 3, 1044 | "confidence": "medium" 1045 | }, 1046 | { 1047 | "shadow": "rgba(50, 50, 93, 0.25) 0px 2px 4px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px", 1048 | "count": 2, 1049 | "confidence": "low" 1050 | }, 1051 | { 1052 | "shadow": "rgba(82, 95, 127, 0.04) -3px -3px 5px 0px", 1053 | "count": 1, 1054 | "confidence": "low" 1055 | }, 1056 | { 1057 | "shadow": "rgba(255, 255, 255, 0.1) 0px 1px 1px 0px inset, rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px", 1058 | "count": 1, 1059 | "confidence": "low" 1060 | }, 1061 | { 1062 | "shadow": "rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px, rgba(10, 37, 64, 0.35) 0px -2px 6px 0px inset", 1063 | "count": 1, 1064 | "confidence": "low" 1065 | }, 1066 | { 1067 | "shadow": "rgba(50, 50, 93, 0.12) 0px 6px 12px -2px, rgba(0, 0, 0, 0.15) 0px 3px 7px -3px", 1068 | "count": 1, 1069 | "confidence": "low" 1070 | }, 1071 | { 1072 | "shadow": "rgba(0, 0, 0, 0.06) 0px -16px 32px -12px, rgba(0, 0, 0, 0.04) 0px -8px 16px 0px", 1073 | "count": 1, 1074 | "confidence": "low" 1075 | } 1076 | ], 1077 | "components": { 1078 | "buttons": [ 1079 | { 1080 | "backgroundColor": "rgba(0, 0, 0, 0)", 1081 | "color": "rgb(255, 255, 255)", 1082 | "padding": "10px 20px", 1083 | "borderRadius": "0px", 1084 | "border": "0px none rgb(255, 255, 255)", 1085 | "fontWeight": "500", 1086 | "fontSize": "15px", 1087 | "classes": "\n SiteHeaderNavItem__link\n SiteHeaderNavItem__link--hasCaret\n ", 1088 | "confidence": "high" 1089 | } 1090 | ], 1091 | "inputs": [ 1092 | { 1093 | "type": "input", 1094 | "border": "1px solid rgba(171, 181, 197, 0.3)", 1095 | "borderRadius": "32px", 1096 | "padding": "9.5px 138px 9.5px 18px", 1097 | "backgroundColor": "rgb(246, 249, 251)", 1098 | "focusStyles": { 1099 | "outline": "rgb(10, 37, 64) none 0px" 1100 | } 1101 | } 1102 | ] 1103 | }, 1104 | "breakpoints": [ 1105 | { 1106 | "px": "375px" 1107 | }, 1108 | { 1109 | "px": "599px" 1110 | }, 1111 | { 1112 | "px": "600px" 1113 | }, 1114 | { 1115 | "px": "672px" 1116 | }, 1117 | { 1118 | "px": "700px" 1119 | }, 1120 | { 1121 | "px": "750px" 1122 | }, 1123 | { 1124 | "px": "899px" 1125 | }, 1126 | { 1127 | "px": "900px" 1128 | }, 1129 | { 1130 | "px": "960px" 1131 | }, 1132 | { 1133 | "px": "990px" 1134 | }, 1135 | { 1136 | "px": "1069px" 1137 | }, 1138 | { 1139 | "px": "1111px" 1140 | }, 1141 | { 1142 | "px": "1112px" 1143 | } 1144 | ], 1145 | "iconSystem": [ 1146 | { 1147 | "name": "SVG Icons", 1148 | "type": "svg" 1149 | } 1150 | ], 1151 | "frameworks": [] 1152 | } 1153 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Dembrandt - Design Token Extraction CLI 5 | * 6 | * Extracts design tokens, brand colors, typography, spacing, and component styles 7 | * from any website using Playwright with advanced bot detection avoidance. 8 | */ 9 | 10 | import { program } from "commander"; 11 | import chalk from "chalk"; 12 | import ora from "ora"; 13 | import { chromium } from "playwright"; 14 | import { extractBranding } from "./lib/extractors.js"; 15 | import { displayResults } from "./lib/display.js"; 16 | import { writeFileSync, mkdirSync } from "fs"; 17 | import { join } from "path"; 18 | 19 | program 20 | .name("dembrandt") 21 | .description("Extract design tokens from any website") 22 | .version("1.0.0") 23 | .argument("") 24 | .option("--json-only", "Output raw JSON") 25 | .option("-d, --debug", "Force visible browser") 26 | .option("--verbose-colors", "Show medium and low confidence colors") 27 | .option("--dark-mode", "Extract colors from dark mode") 28 | .option("--mobile", "Extract from mobile viewport") 29 | .option("--slow", "3x longer timeouts for slow-loading sites") 30 | .action(async (input, opts) => { 31 | let url = input; 32 | if (!url.match(/^https?:\/\//)) url = "https://" + url; 33 | 34 | const spinner = ora("Starting extraction...").start(); 35 | let browser = null; 36 | 37 | try { 38 | let useHeaded = opts.debug; 39 | let result; 40 | 41 | while (true) { 42 | spinner.text = `Launching browser (${ 43 | useHeaded ? "visible" : "headless" 44 | } mode)`; 45 | browser = await chromium.launch({ 46 | headless: !useHeaded, 47 | args: [ 48 | "--no-sandbox", 49 | "--disable-setuid-sandbox", 50 | "--disable-blink-features=AutomationControlled", 51 | ], 52 | }); 53 | if (opts.debug) { 54 | console.log( 55 | chalk.dim( 56 | ` ✓ Browser launched in ${ 57 | useHeaded ? "visible" : "headless" 58 | } mode` 59 | ) 60 | ); 61 | } 62 | 63 | try { 64 | result = await extractBranding(url, spinner, browser, { 65 | navigationTimeout: 90000, 66 | darkMode: opts.darkMode, 67 | mobile: opts.mobile, 68 | slow: opts.slow, 69 | }); 70 | break; 71 | } catch (err) { 72 | await browser.close(); 73 | browser = null; 74 | 75 | if (useHeaded) throw err; 76 | 77 | if ( 78 | err.message.includes("Timeout") || 79 | err.message.includes("net::ERR_") 80 | ) { 81 | spinner.warn( 82 | "Bot detection detected → retrying with visible browser" 83 | ); 84 | console.error(chalk.dim(` ↳ Error: ${err.message}`)); 85 | console.error(chalk.dim(` ↳ URL: ${url}`)); 86 | console.error(chalk.dim(` ↳ Mode: headless`)); 87 | useHeaded = true; 88 | continue; 89 | } 90 | throw err; 91 | } 92 | } 93 | 94 | console.log(); 95 | 96 | // Save JSON output automatically (unless --json-only) 97 | if (!opts.jsonOnly) { 98 | try { 99 | const domain = new URL(url).hostname.replace("www.", ""); 100 | const timestamp = new Date() 101 | .toISOString() 102 | .replace(/[:.]/g, "-") 103 | .split(".")[0]; 104 | // Save to current working directory, not installation directory 105 | const outputDir = join(process.cwd(), "output", domain); 106 | mkdirSync(outputDir, { recursive: true }); 107 | 108 | const filename = `${timestamp}.json`; 109 | const filepath = join(outputDir, filename); 110 | writeFileSync(filepath, JSON.stringify(result, null, 2)); 111 | 112 | console.log( 113 | chalk.dim( 114 | `💾 JSON saved to: ${chalk.hex('#8BE9FD')( 115 | `output/${domain}/${filename}` 116 | )}` 117 | ) 118 | ); 119 | } catch (err) { 120 | console.log( 121 | chalk.hex('#FFB86C')(`⚠ Could not save JSON file: ${err.message}`) 122 | ); 123 | } 124 | } 125 | 126 | // Output to terminal 127 | if (opts.jsonOnly) { 128 | console.log(JSON.stringify(result, null, 2)); 129 | } else { 130 | console.log(); 131 | displayResults(result, { verboseColors: opts.verboseColors }); 132 | } 133 | } catch (err) { 134 | spinner.fail("Failed"); 135 | console.error(chalk.red("\n✗ Extraction failed")); 136 | console.error(chalk.red(` Error: ${err.message}`)); 137 | console.error(chalk.dim(` URL: ${url}`)); 138 | 139 | if (opts.debug && err.stack) { 140 | console.error(chalk.dim("\nStack trace:")); 141 | console.error(chalk.dim(err.stack)); 142 | } 143 | 144 | if (!opts.debug) { 145 | console.log( 146 | chalk.hex('#FFB86C')( 147 | "\nTip: Try with --debug flag for tough sites and detailed error logs" 148 | ) 149 | ); 150 | } 151 | process.exit(1); 152 | } finally { 153 | if (browser) await browser.close(); 154 | } 155 | }); 156 | 157 | program.parse(); 158 | -------------------------------------------------------------------------------- /lib/display.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Terminal Display Formatter 3 | * 4 | * Formats extracted brand data into clean, readable terminal output 5 | * with color swatches and minimal design. 6 | */ 7 | 8 | import chalk from 'chalk'; 9 | 10 | /** 11 | * Creates a clickable terminal link using ANSI escape codes 12 | * Supported in iTerm2, VSCode terminal, GNOME Terminal, Windows Terminal 13 | * @param {string} url - The URL to link to 14 | * @param {string} text - The text to display (defaults to url) 15 | * @returns {string} ANSI-formatted clickable link 16 | */ 17 | function terminalLink(url, text = url) { 18 | // OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\ 19 | return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; 20 | } 21 | 22 | /** 23 | * Main display function - outputs formatted extraction results to terminal 24 | * @param {Object} data - Extraction results from extractBranding() 25 | * @param {Object} options - Display options (verboseColors, etc.) 26 | */ 27 | export function displayResults(data, options = {}) { 28 | console.log('\n' + chalk.bold.cyan('🎨 Brand Extraction')); 29 | console.log(chalk.dim('│')); 30 | console.log(chalk.dim('├─') + ' ' + chalk.blue(terminalLink(data.url))); 31 | const timeString = new Date(data.extractedAt).toLocaleTimeString('en-US', { 32 | minute: '2-digit', 33 | second: '2-digit' 34 | }); 35 | console.log(chalk.dim('├─') + ' ' + chalk.dim(timeString)); 36 | console.log(chalk.dim('│')); 37 | 38 | displayLogo(data.logo); 39 | displayFavicons(data.favicons); 40 | displayColors(data.colors, options.verboseColors); 41 | displayTypography(data.typography); 42 | displaySpacing(data.spacing); 43 | displayBorderRadius(data.borderRadius); 44 | displayBorders(data.borders); 45 | displayShadows(data.shadows); 46 | displayButtons(data.components?.buttons); 47 | displayInputs(data.components?.inputs); 48 | displayLinks(data.components?.links); 49 | displayBreakpoints(data.breakpoints); 50 | displayIconSystem(data.iconSystem); 51 | displayFrameworks(data.frameworks); 52 | 53 | console.log(chalk.dim('│')); 54 | console.log(chalk.dim('└─') + ' ' + chalk.hex('#50FA7B')('✓ Complete')); 55 | console.log(''); 56 | } 57 | 58 | function displayLogo(logo) { 59 | if (!logo) return; 60 | 61 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Logo')); 62 | 63 | if (logo.url) { 64 | console.log(chalk.dim('│ ├─') + ' ' + chalk.blue(terminalLink(logo.url))); 65 | } 66 | 67 | if (logo.width && logo.height) { 68 | console.log(chalk.dim('│ ├─') + ' ' + chalk.dim(`${logo.width}×${logo.height}px`)); 69 | } 70 | 71 | if (logo.safeZone) { 72 | const { top, right, bottom, left } = logo.safeZone; 73 | if (top > 0 || right > 0 || bottom > 0 || left > 0) { 74 | console.log(chalk.dim('│ └─') + ' ' + chalk.dim(`Safe zone: ${top}px ${right}px ${bottom}px ${left}px`)); 75 | } 76 | } 77 | 78 | console.log(chalk.dim('│')); 79 | } 80 | 81 | function displayFavicons(favicons) { 82 | if (!favicons || favicons.length === 0) return; 83 | 84 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Favicons')); 85 | 86 | favicons.forEach((favicon, index) => { 87 | const isLast = index === favicons.length - 1; 88 | const branch = isLast ? '└─' : '├─'; 89 | const sizeInfo = favicon.sizes ? chalk.dim(` · ${favicon.sizes}`) : ''; 90 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${chalk.hex('#8BE9FD')(favicon.type.padEnd(18))} ${terminalLink(favicon.url)}${sizeInfo}`); 91 | }); 92 | 93 | console.log(chalk.dim('│')); 94 | } 95 | 96 | function normalizeColorFormat(colorString) { 97 | // Return both hex and rgb formats 98 | const rgbaMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); 99 | if (rgbaMatch) { 100 | const r = parseInt(rgbaMatch[1]); 101 | const g = parseInt(rgbaMatch[2]); 102 | const b = parseInt(rgbaMatch[3]); 103 | const a = rgbaMatch[4]; 104 | 105 | const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; 106 | const rgb = a ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`; 107 | 108 | return { hex, rgb, hasAlpha: !!a }; 109 | } 110 | 111 | // Match 3-digit hex (#fff, #f0a, etc.) 112 | const hexMatch3 = colorString.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); 113 | if (hexMatch3) { 114 | // Expand 3-digit to 6-digit (#fff → #ffffff) 115 | const r = parseInt(hexMatch3[1] + hexMatch3[1], 16); 116 | const g = parseInt(hexMatch3[2] + hexMatch3[2], 16); 117 | const b = parseInt(hexMatch3[3] + hexMatch3[3], 16); 118 | 119 | return { 120 | hex: `#${hexMatch3[1]}${hexMatch3[1]}${hexMatch3[2]}${hexMatch3[2]}${hexMatch3[3]}${hexMatch3[3]}`.toLowerCase(), 121 | rgb: `rgb(${r}, ${g}, ${b})`, 122 | hasAlpha: false 123 | }; 124 | } 125 | 126 | // Match 8-digit hex with alpha (#ffffff80, #00ff00ff, etc.) 127 | const hexMatch8 = colorString.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); 128 | if (hexMatch8) { 129 | const r = parseInt(hexMatch8[1], 16); 130 | const g = parseInt(hexMatch8[2], 16); 131 | const b = parseInt(hexMatch8[3], 16); 132 | const a = (parseInt(hexMatch8[4], 16) / 255).toFixed(2); 133 | 134 | return { 135 | hex: `#${hexMatch8[1]}${hexMatch8[2]}${hexMatch8[3]}`.toLowerCase(), 136 | rgb: `rgba(${r}, ${g}, ${b}, ${a})`, 137 | hasAlpha: true 138 | }; 139 | } 140 | 141 | // Match 6-digit hex (#ffffff, #f0a0b0, etc.) 142 | const hexMatch6 = colorString.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); 143 | if (hexMatch6) { 144 | const r = parseInt(hexMatch6[1], 16); 145 | const g = parseInt(hexMatch6[2], 16); 146 | const b = parseInt(hexMatch6[3], 16); 147 | 148 | return { 149 | hex: colorString.toLowerCase(), 150 | rgb: `rgb(${r}, ${g}, ${b})`, 151 | hasAlpha: false 152 | }; 153 | } 154 | 155 | return { hex: colorString, rgb: colorString, hasAlpha: false }; 156 | } 157 | 158 | function displayColors(colors, verboseColors = false) { 159 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Colors')); 160 | 161 | // All colors in one list with consistent formatting 162 | const allColors = []; 163 | 164 | // Add semantic colors 165 | if (colors.semantic) { 166 | Object.entries(colors.semantic) 167 | .filter(([_, color]) => color) 168 | .forEach(([role, color]) => { 169 | const formats = normalizeColorFormat(color); 170 | allColors.push({ 171 | hex: formats.hex, 172 | rgb: formats.rgb, 173 | hasAlpha: formats.hasAlpha, 174 | label: role, 175 | type: 'semantic', 176 | confidence: 'high' 177 | }); 178 | }); 179 | } 180 | 181 | // Add CSS variables 182 | if (colors.cssVariables) { 183 | const limit = verboseColors ? 30 : 15; 184 | Object.entries(colors.cssVariables).slice(0, limit).forEach(([name, value]) => { 185 | try { 186 | const formats = normalizeColorFormat(value); 187 | allColors.push({ 188 | hex: formats.hex, 189 | rgb: formats.rgb, 190 | hasAlpha: formats.hasAlpha, 191 | label: name, 192 | type: 'variable', 193 | confidence: 'high' 194 | }); 195 | } catch { 196 | // Skip invalid colors 197 | } 198 | }); 199 | } 200 | 201 | // Add palette colors - filter based on verboseColors flag 202 | if (colors.palette) { 203 | const limit = verboseColors ? 40 : 20; 204 | const filtered = verboseColors 205 | ? colors.palette // Show all confidence levels in verbose mode 206 | : colors.palette.filter(c => c.confidence === 'high' || c.confidence === 'medium'); 207 | 208 | filtered.slice(0, limit).forEach(c => { 209 | const formats = normalizeColorFormat(c.color); 210 | allColors.push({ 211 | hex: formats.hex, 212 | rgb: formats.rgb, 213 | hasAlpha: formats.hasAlpha, 214 | label: '', 215 | type: 'palette', 216 | confidence: c.confidence 217 | }); 218 | }); 219 | } 220 | 221 | // Deduplicate colors by hex value 222 | const colorMap = new Map(); 223 | allColors.forEach(color => { 224 | const key = color.hex.toLowerCase(); 225 | if (colorMap.has(key)) { 226 | const existing = colorMap.get(key); 227 | // Merge labels 228 | if (color.label && !existing.label) { 229 | existing.label = color.label; 230 | } else if (color.label && existing.label) { 231 | // Split existing labels and check for exact match 232 | const existingLabels = existing.label.split(', '); 233 | if (!existingLabels.includes(color.label)) { 234 | existing.label = `${existing.label}, ${color.label}`; 235 | } 236 | } 237 | // Keep highest confidence 238 | const confidenceOrder = { high: 3, medium: 2, low: 1 }; 239 | if (confidenceOrder[color.confidence] > confidenceOrder[existing.confidence]) { 240 | existing.confidence = color.confidence; 241 | } 242 | } else { 243 | colorMap.set(key, { ...color }); 244 | } 245 | }); 246 | 247 | const uniqueColors = Array.from(colorMap.values()); 248 | 249 | // Display all colors with both hex and RGB in grid format 250 | uniqueColors.forEach(({ hex, rgb, label, confidence }, index) => { 251 | const isLast = index === uniqueColors.length - 1; 252 | const branch = isLast ? '└─' : '├─'; 253 | 254 | try { 255 | const colorBlock = chalk.bgHex(hex)(' '); 256 | let conf; 257 | if (confidence === 'high') conf = chalk.hex('#50FA7B')('●'); 258 | else if (confidence === 'medium') conf = chalk.hex('#FFB86C')('●'); 259 | else conf = chalk.gray('●'); // low confidence 260 | 261 | const labelText = label ? chalk.dim(label) : ''; 262 | 263 | // Show hex and RGB side by side for easy copying 264 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${conf} ${colorBlock} ${hex.padEnd(9)} ${rgb.padEnd(22)} ${labelText}`); 265 | } catch { 266 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${hex.padEnd(9)} ${rgb.padEnd(22)} ${label ? chalk.dim(label) : ''}`); 267 | } 268 | }); 269 | 270 | const cssVarLimit = verboseColors ? 30 : 15; 271 | const paletteLimit = verboseColors ? 40 : 20; 272 | const remaining = (colors.cssVariables ? Math.max(0, Object.keys(colors.cssVariables).length - cssVarLimit) : 0) + 273 | (colors.palette ? Math.max(0, colors.palette.length - paletteLimit) : 0); 274 | if (remaining > 0) { 275 | console.log(chalk.dim(`│ └─`) + ' ' + chalk.dim(`+${remaining} more in JSON`)); 276 | } 277 | console.log(chalk.dim('│')); 278 | } 279 | 280 | function displayTypography(typography) { 281 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Typography')); 282 | 283 | // Font sources with font-display 284 | const sources = []; 285 | if (typography.sources?.googleFonts?.length > 0) { 286 | sources.push(...typography.sources.googleFonts); 287 | } 288 | if (sources.length > 0) { 289 | const fontDisplayInfo = typography.sources?.fontDisplay ? ` · font-display: ${typography.sources.fontDisplay}` : ''; 290 | console.log(chalk.dim('│ ├─') + ' ' + chalk.dim(`Fonts: ${sources.slice(0, 3).join(', ')}${fontDisplayInfo}`)); 291 | if (sources.length > 3) { 292 | console.log(chalk.dim('│ ├─') + ' ' + chalk.dim(`+${sources.length - 3} more`)); 293 | } 294 | } 295 | 296 | // Group styles by font family, then by context 297 | if (typography.styles?.length > 0) { 298 | const fontFamilies = new Map(); 299 | 300 | typography.styles.forEach(style => { 301 | // Skip styles without a family name 302 | if (!style.family) return; 303 | 304 | if (!fontFamilies.has(style.family)) { 305 | fontFamilies.set(style.family, { 306 | fallbacks: style.fallbacks, 307 | contexts: new Map() 308 | }); 309 | } 310 | 311 | const familyData = fontFamilies.get(style.family); 312 | const contextKey = style.context || 'unknown'; 313 | if (!familyData.contexts.has(contextKey)) { 314 | familyData.contexts.set(contextKey, []); 315 | } 316 | familyData.contexts.get(contextKey).push(style); 317 | }); 318 | 319 | let fontIndex = 0; 320 | const totalFonts = fontFamilies.size; 321 | 322 | for (const [family, data] of fontFamilies) { 323 | fontIndex++; 324 | const isFontLast = fontIndex === totalFonts; 325 | const fontBranch = isFontLast ? '└─' : '├─'; 326 | 327 | console.log(chalk.dim(`│ ${fontBranch}`) + ' ' + chalk.bold(family)); 328 | 329 | if (data.fallbacks) { 330 | const indent = isFontLast ? ' ' : '│ '; 331 | console.log(chalk.dim(`│ ${indent}├─`) + ' ' + chalk.dim(`fallbacks: ${data.fallbacks}`)); 332 | } 333 | 334 | let contextIndex = 0; 335 | const totalContexts = data.contexts.size; 336 | 337 | for (const [context, styles] of data.contexts) { 338 | contextIndex++; 339 | const isContextLast = contextIndex === totalContexts; 340 | const indent = isFontLast ? ' ' : '│ '; 341 | const contextBranch = isContextLast ? '└─' : '├─'; 342 | 343 | console.log(chalk.dim(`│ ${indent}${contextBranch}`) + ' ' + chalk.hex('#8BE9FD')(context)); 344 | 345 | styles.forEach((style, styleIndex) => { 346 | const modifiers = []; 347 | 348 | if (style.weight && style.weight !== 400) { 349 | modifiers.push(`w${style.weight}`); 350 | } 351 | 352 | if (style.lineHeight) { 353 | const lh = parseFloat(style.lineHeight); 354 | let lhLabel = ''; 355 | if (lh <= 1.3) lhLabel = 'tight'; 356 | else if (lh >= 1.6) lhLabel = 'relaxed'; 357 | modifiers.push(lhLabel ? `lh${style.lineHeight}(${lhLabel})` : `lh${style.lineHeight}`); 358 | } 359 | 360 | if (style.transform) modifiers.push(style.transform); 361 | if (style.spacing) modifiers.push(`ls${style.spacing}`); 362 | if (style.isFluid) modifiers.push('fluid'); 363 | if (style.fontFeatures) modifiers.push('features'); 364 | 365 | const modifierStr = modifiers.length > 0 ? ` ${chalk.dim('[' + modifiers.join(' ') + ']')}` : ''; 366 | 367 | const isStyleLast = styleIndex === styles.length - 1; 368 | const styleIndent = isFontLast ? ' ' : '│ '; 369 | const contextIndent = isContextLast ? ' ' : '│ '; 370 | const styleBranch = isStyleLast ? '└─' : '├─'; 371 | 372 | console.log(chalk.dim(`│ ${styleIndent}${contextIndent}${styleBranch}`) + ' ' + `${style.size}${modifierStr}`); 373 | }); 374 | } 375 | } 376 | } 377 | console.log(chalk.dim('│')); 378 | } 379 | 380 | function displaySpacing(spacing) { 381 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Spacing')); 382 | console.log(chalk.dim('│ ├─') + ' ' + chalk.dim(`System: ${spacing.scaleType}`)); 383 | spacing.commonValues.slice(0, 15).forEach((v, index) => { 384 | const isLast = index === Math.min(spacing.commonValues.length, 15) - 1; 385 | const branch = isLast ? '└─' : '├─'; 386 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${v.px.padEnd(8)} ${chalk.dim(v.rem)}`); 387 | }); 388 | console.log(chalk.dim('│')); 389 | } 390 | 391 | function displayBorderRadius(borderRadius) { 392 | if (!borderRadius || borderRadius.values.length === 0) return; 393 | 394 | const highConfRadius = borderRadius.values.filter(r => r.confidence === 'high' || r.confidence === 'medium'); 395 | if (highConfRadius.length === 0) return; 396 | 397 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Border Radius')); 398 | 399 | highConfRadius.slice(0, 12).forEach((r, index) => { 400 | const isLast = index === highConfRadius.slice(0, 12).length - 1; 401 | const branch = isLast ? '└─' : '├─'; 402 | const elements = r.elements && r.elements.length > 0 403 | ? chalk.dim(` (${r.elements.join(', ')})`) 404 | : ''; 405 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${r.value}${elements}`); 406 | }); 407 | 408 | console.log(chalk.dim('│')); 409 | } 410 | 411 | function displayBorders(borders) { 412 | if (!borders) return; 413 | 414 | const hasCombinations = borders.combinations && borders.combinations.length > 0; 415 | if (!hasCombinations) return; 416 | 417 | const highConfCombos = borders.combinations.filter(c => c.confidence === 'high' || c.confidence === 'medium'); 418 | if (highConfCombos.length === 0) return; 419 | 420 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Borders')); 421 | 422 | highConfCombos.slice(0, 10).forEach((combo, index) => { 423 | const isLast = index === Math.min(highConfCombos.length, 10) - 1; 424 | const branch = isLast ? '└─' : '├─'; 425 | const conf = combo.confidence === 'high' ? chalk.hex('#50FA7B')('●') : chalk.hex('#FFB86C')('●'); 426 | 427 | try { 428 | const formats = normalizeColorFormat(combo.color); 429 | const colorBlock = chalk.bgHex(formats.hex)(' '); 430 | 431 | const elementsText = combo.elements && combo.elements.length > 0 432 | ? chalk.dim(` (${combo.elements.join(', ')})`) 433 | : ''; 434 | 435 | console.log( 436 | chalk.dim(`│ ${branch}`) + ' ' + 437 | `${conf} ${colorBlock} ${combo.width} ${combo.style} ${formats.hex.padEnd(9)} ${formats.rgb}` + 438 | elementsText 439 | ); 440 | } catch { 441 | const elementsText = combo.elements && combo.elements.length > 0 442 | ? chalk.dim(` (${combo.elements.join(', ')})`) 443 | : ''; 444 | 445 | console.log( 446 | chalk.dim(`│ ${branch}`) + ' ' + 447 | `${conf} ${combo.width} ${combo.style} ${combo.color}` + 448 | elementsText 449 | ); 450 | } 451 | }); 452 | 453 | if (highConfCombos.length > 10) { 454 | console.log(chalk.dim('│ └─') + ' ' + chalk.dim(`+${highConfCombos.length - 10} more`)); 455 | } 456 | 457 | console.log(chalk.dim('│')); 458 | } 459 | 460 | function displayShadows(shadows) { 461 | if (!shadows || shadows.length === 0) return; 462 | 463 | const highConfShadows = shadows.filter(s => s.confidence === 'high' || s.confidence === 'medium'); 464 | if (highConfShadows.length === 0) return; 465 | 466 | // Sort by confidence first (high > medium), then by count 467 | const sorted = highConfShadows.sort((a, b) => { 468 | const confOrder = { 'high': 2, 'medium': 1 }; 469 | const confDiff = (confOrder[b.confidence] || 0) - (confOrder[a.confidence] || 0); 470 | if (confDiff !== 0) return confDiff; 471 | return (b.count || 0) - (a.count || 0); // Higher count first 472 | }); 473 | 474 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Shadows')); 475 | sorted.slice(0, 8).forEach((s, index) => { 476 | const isLast = index === Math.min(sorted.length, 8) - 1 && sorted.length <= 8; 477 | const branch = isLast ? '└─' : '├─'; 478 | const conf = s.confidence === 'high' ? chalk.hex('#50FA7B')('●') : chalk.hex('#FFB86C')('●'); 479 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${conf} ${s.shadow}`); 480 | }); 481 | if (highConfShadows.length > 8) { 482 | console.log(chalk.dim('│ └─') + ' ' + chalk.dim(`+${highConfShadows.length - 8} more`)); 483 | } 484 | console.log(chalk.dim('│')); 485 | } 486 | 487 | function displayButtons(buttons) { 488 | if (!buttons || buttons.length === 0) return; 489 | 490 | const highConfButtons = buttons.filter(b => b.confidence === 'high'); 491 | if (highConfButtons.length === 0) return; 492 | 493 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Buttons')); 494 | 495 | highConfButtons.slice(0, 6).forEach((btn, btnIndex) => { 496 | const isLastBtn = btnIndex === Math.min(highConfButtons.length, 6) - 1 && highConfButtons.length <= 6; 497 | const btnBranch = isLastBtn ? '└─' : '├─'; 498 | const btnIndent = isLastBtn ? ' ' : '│ '; 499 | 500 | // Show button variant header 501 | try { 502 | const defaultBg = btn.states.default.backgroundColor; 503 | const isTransparent = defaultBg.includes('rgba(0, 0, 0, 0)') || defaultBg === 'transparent'; 504 | 505 | if (isTransparent) { 506 | console.log(chalk.dim(`│ ${btnBranch}`) + ' ' + chalk.bold('Variant: transparent')); 507 | } else { 508 | const formats = normalizeColorFormat(defaultBg); 509 | const colorBlock = chalk.bgHex(formats.hex)(' '); 510 | console.log(chalk.dim(`│ ${btnBranch}`) + ' ' + chalk.bold(`Variant: ${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}`)); 511 | } 512 | } catch { 513 | console.log(chalk.dim(`│ ${btnBranch}`) + ' ' + chalk.bold(`Variant: ${btn.states.default.backgroundColor}`)); 514 | } 515 | 516 | // Display states 517 | const stateOrder = [ 518 | { key: 'default', label: 'Default (Rest)' }, 519 | { key: 'hover', label: 'Hover' }, 520 | { key: 'active', label: 'Active (Pressed)' }, 521 | { key: 'focus', label: 'Focus' }, 522 | ]; 523 | 524 | const availableStates = stateOrder.filter(s => btn.states[s.key]); 525 | 526 | availableStates.forEach((stateInfo, stateIndex) => { 527 | const state = btn.states[stateInfo.key]; 528 | const isLastState = stateIndex === availableStates.length - 1; 529 | const stateBranch = isLastState ? '└─' : '├─'; 530 | const stateIndent = isLastState ? ' ' : '│ '; 531 | 532 | console.log(chalk.dim(`│ ${btnIndent}${stateBranch}`) + ' ' + chalk.hex('#8BE9FD')(stateInfo.label)); 533 | 534 | const props = []; 535 | 536 | // Only show properties that exist and are meaningful 537 | if (state.backgroundColor && state.backgroundColor !== 'rgba(0, 0, 0, 0)' && state.backgroundColor !== 'transparent') { 538 | try { 539 | const formats = normalizeColorFormat(state.backgroundColor); 540 | const colorBlock = chalk.bgHex(formats.hex)(' '); 541 | props.push({ key: 'bg', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 542 | } catch { 543 | props.push({ key: 'bg', value: state.backgroundColor }); 544 | } 545 | } 546 | 547 | if (state.color) { 548 | try { 549 | const formats = normalizeColorFormat(state.color); 550 | const colorBlock = chalk.bgHex(formats.hex)(' '); 551 | props.push({ key: 'text', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 552 | } catch { 553 | props.push({ key: 'text', value: state.color }); 554 | } 555 | } 556 | 557 | if (stateInfo.key === 'default') { 558 | if (state.padding && state.padding !== '0px') { 559 | props.push({ key: 'padding', value: state.padding }); 560 | } 561 | if (state.borderRadius && state.borderRadius !== '0px') { 562 | props.push({ key: 'radius', value: state.borderRadius }); 563 | } 564 | } 565 | 566 | if (state.border && state.border !== 'none' && !state.border.includes('0px')) { 567 | props.push({ key: 'border', value: state.border }); 568 | } 569 | 570 | if (state.boxShadow && state.boxShadow !== 'none') { 571 | const shortShadow = state.boxShadow.length > 40 572 | ? state.boxShadow.substring(0, 37) + '...' 573 | : state.boxShadow; 574 | props.push({ key: 'shadow', value: shortShadow }); 575 | } 576 | 577 | if (state.outline && state.outline !== 'none') { 578 | props.push({ key: 'outline', value: state.outline }); 579 | } 580 | 581 | if (state.transform && state.transform !== 'none') { 582 | props.push({ key: 'transform', value: state.transform }); 583 | } 584 | 585 | if (state.opacity && state.opacity !== '1') { 586 | props.push({ key: 'opacity', value: state.opacity }); 587 | } 588 | 589 | // Display properties 590 | props.forEach((prop, propIndex) => { 591 | const isLastProp = propIndex === props.length - 1; 592 | const propBranch = isLastProp ? '└─' : '├─'; 593 | console.log( 594 | chalk.dim(`│ ${btnIndent}${stateIndent}${propBranch}`) + ' ' + 595 | chalk.dim(`${prop.key}: `) + `${prop.value}` 596 | ); 597 | }); 598 | }); 599 | }); 600 | 601 | if (highConfButtons.length > 6) { 602 | console.log(chalk.dim('│ └─') + ' ' + chalk.dim(`+${highConfButtons.length - 6} more`)); 603 | } 604 | console.log(chalk.dim('│')); 605 | } 606 | 607 | function displayInputs(inputs) { 608 | if (!inputs) return; 609 | 610 | const hasText = inputs.text && inputs.text.length > 0; 611 | const hasCheckbox = inputs.checkbox && inputs.checkbox.length > 0; 612 | const hasRadio = inputs.radio && inputs.radio.length > 0; 613 | const hasSelect = inputs.select && inputs.select.length > 0; 614 | 615 | if (!hasText && !hasCheckbox && !hasRadio && !hasSelect) return; 616 | 617 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Inputs')); 618 | 619 | const displayGroup = (groupName, items, isLastGroup) => { 620 | if (!items || items.length === 0) return; 621 | 622 | const groupBranch = isLastGroup ? '└─' : '├─'; 623 | const groupIndent = isLastGroup ? ' ' : '│ '; 624 | 625 | console.log(chalk.dim(`│ ${groupBranch}`) + ' ' + chalk.bold(groupName)); 626 | 627 | items.forEach((input, index) => { 628 | const isLast = index === items.length - 1; 629 | const branch = isLast ? '└─' : '├─'; 630 | const indent = isLast ? ' ' : '│ '; 631 | 632 | console.log(chalk.dim(`│ ${groupIndent}${branch}`) + ' ' + chalk.hex('#8BE9FD')(input.specificType)); 633 | 634 | // Display default state 635 | const defaultState = input.states.default; 636 | console.log(chalk.dim(`│ ${groupIndent}${indent}├─`) + ' ' + chalk.hex('#8BE9FD')('Default')); 637 | 638 | const defaultProps = []; 639 | 640 | if (defaultState.backgroundColor && defaultState.backgroundColor !== 'rgba(0, 0, 0, 0)' && defaultState.backgroundColor !== 'transparent') { 641 | try { 642 | const formats = normalizeColorFormat(defaultState.backgroundColor); 643 | const colorBlock = chalk.bgHex(formats.hex)(' '); 644 | defaultProps.push({ key: 'bg', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 645 | } catch { 646 | defaultProps.push({ key: 'bg', value: defaultState.backgroundColor }); 647 | } 648 | } 649 | 650 | if (defaultState.color) { 651 | try { 652 | const formats = normalizeColorFormat(defaultState.color); 653 | const colorBlock = chalk.bgHex(formats.hex)(' '); 654 | defaultProps.push({ key: 'text', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 655 | } catch { 656 | defaultProps.push({ key: 'text', value: defaultState.color }); 657 | } 658 | } 659 | 660 | if (defaultState.border && defaultState.border !== 'none' && !defaultState.border.includes('0px')) { 661 | defaultProps.push({ key: 'border', value: defaultState.border }); 662 | } 663 | 664 | if (defaultState.padding && defaultState.padding !== '0px') { 665 | defaultProps.push({ key: 'padding', value: defaultState.padding }); 666 | } 667 | 668 | if (defaultState.borderRadius && defaultState.borderRadius !== '0px') { 669 | defaultProps.push({ key: 'radius', value: defaultState.borderRadius }); 670 | } 671 | 672 | defaultProps.forEach((prop, propIndex) => { 673 | const isLastProp = propIndex === defaultProps.length - 1 && !input.states.focus; 674 | const propBranch = isLastProp ? '└─' : '├─'; 675 | console.log( 676 | chalk.dim(`│ ${groupIndent}${indent}│ ${propBranch}`) + ' ' + 677 | chalk.dim(`${prop.key}: `) + `${prop.value}` 678 | ); 679 | }); 680 | 681 | // Display focus state if available 682 | if (input.states.focus) { 683 | const focusState = input.states.focus; 684 | console.log(chalk.dim(`│ ${groupIndent}${indent}└─`) + ' ' + chalk.hex('#8BE9FD')('Focus')); 685 | 686 | const focusProps = []; 687 | 688 | if (focusState.backgroundColor) { 689 | try { 690 | const formats = normalizeColorFormat(focusState.backgroundColor); 691 | const colorBlock = chalk.bgHex(formats.hex)(' '); 692 | focusProps.push({ key: 'bg', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 693 | } catch { 694 | focusProps.push({ key: 'bg', value: focusState.backgroundColor }); 695 | } 696 | } 697 | 698 | if (focusState.border) { 699 | focusProps.push({ key: 'border', value: focusState.border }); 700 | } 701 | 702 | if (focusState.borderColor) { 703 | try { 704 | const formats = normalizeColorFormat(focusState.borderColor); 705 | const colorBlock = chalk.bgHex(formats.hex)(' '); 706 | focusProps.push({ key: 'border-color', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 707 | } catch { 708 | focusProps.push({ key: 'border-color', value: focusState.borderColor }); 709 | } 710 | } 711 | 712 | if (focusState.boxShadow && focusState.boxShadow !== 'none') { 713 | const shortShadow = focusState.boxShadow.length > 40 714 | ? focusState.boxShadow.substring(0, 37) + '...' 715 | : focusState.boxShadow; 716 | focusProps.push({ key: 'shadow', value: shortShadow }); 717 | } 718 | 719 | if (focusState.outline && focusState.outline !== 'none') { 720 | focusProps.push({ key: 'outline', value: focusState.outline }); 721 | } 722 | 723 | focusProps.forEach((prop, propIndex) => { 724 | const isLastProp = propIndex === focusProps.length - 1; 725 | const propBranch = isLastProp ? '└─' : '├─'; 726 | console.log( 727 | chalk.dim(`│ ${groupIndent}${indent} ${propBranch}`) + ' ' + 728 | chalk.dim(`${prop.key}: `) + `${prop.value}` 729 | ); 730 | }); 731 | } 732 | }); 733 | }; 734 | 735 | let remaining = 0; 736 | if (hasText) remaining++; 737 | if (hasCheckbox) remaining++; 738 | if (hasRadio) remaining++; 739 | if (hasSelect) remaining++; 740 | 741 | if (hasText) { 742 | remaining--; 743 | displayGroup('Text Inputs', inputs.text, remaining === 0); 744 | } 745 | if (hasCheckbox) { 746 | remaining--; 747 | displayGroup('Checkboxes', inputs.checkbox, remaining === 0); 748 | } 749 | if (hasRadio) { 750 | remaining--; 751 | displayGroup('Radio Buttons', inputs.radio, remaining === 0); 752 | } 753 | if (hasSelect) { 754 | remaining--; 755 | displayGroup('Select Dropdowns', inputs.select, remaining === 0); 756 | } 757 | 758 | console.log(chalk.dim('│')); 759 | } 760 | 761 | function displayBreakpoints(breakpoints) { 762 | if (!breakpoints || breakpoints.length === 0) return; 763 | 764 | // Sort from larger to smaller, filtering out invalid entries 765 | const sorted = [...breakpoints] 766 | .filter(bp => bp.px && !isNaN(parseFloat(bp.px))) 767 | .sort((a, b) => { 768 | const aVal = parseFloat(a.px); 769 | const bVal = parseFloat(b.px); 770 | return bVal - aVal; 771 | }); 772 | 773 | if (sorted.length === 0) return; 774 | 775 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Breakpoints')); 776 | console.log(chalk.dim('│ └─') + ' ' + `${sorted.map(bp => bp.px).join(' → ')}`); 777 | console.log(chalk.dim('│')); 778 | } 779 | 780 | function displayLinks(links) { 781 | if (!links || links.length === 0) return; 782 | 783 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Links')); 784 | 785 | links.slice(0, 6).forEach((link, linkIndex) => { 786 | const isLastLink = linkIndex === Math.min(links.length, 6) - 1; 787 | const linkBranch = isLastLink ? '└─' : '├─'; 788 | const linkIndent = isLastLink ? ' ' : '│ '; 789 | 790 | // Show link variant header with color 791 | try { 792 | const formats = normalizeColorFormat(link.color); 793 | const colorBlock = chalk.bgHex(formats.hex)(' '); 794 | console.log(chalk.dim(`│ ${linkBranch}`) + ' ' + `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}`); 795 | } catch { 796 | console.log(chalk.dim(`│ ${linkBranch}`) + ' ' + `${link.color}`); 797 | } 798 | 799 | // Display default state 800 | if (link.states && link.states.default) { 801 | const defaultState = link.states.default; 802 | const hasHover = link.states.hover; 803 | const hasDecoration = defaultState.textDecoration && defaultState.textDecoration !== 'none'; 804 | 805 | // Only show default state if there's decoration or hover state 806 | if (hasDecoration || hasHover) { 807 | console.log(chalk.dim(`│ ${linkIndent}├─`) + ' ' + chalk.hex('#8BE9FD')('Default')); 808 | 809 | if (hasDecoration) { 810 | const decorBranch = hasHover ? '├─' : '└─'; 811 | console.log(chalk.dim(`│ ${linkIndent}│ ${decorBranch}`) + ' ' + chalk.dim(`decoration: ${defaultState.textDecoration}`)); 812 | } 813 | } 814 | 815 | // Display hover state if available 816 | if (hasHover) { 817 | const hoverState = link.states.hover; 818 | console.log(chalk.dim(`│ ${linkIndent}└─`) + ' ' + chalk.hex('#8BE9FD')('Hover')); 819 | 820 | const hoverProps = []; 821 | 822 | if (hoverState.color) { 823 | try { 824 | const formats = normalizeColorFormat(hoverState.color); 825 | const colorBlock = chalk.bgHex(formats.hex)(' '); 826 | hoverProps.push({ key: 'color', value: `${colorBlock} ${formats.hex.padEnd(9)} ${formats.rgb}` }); 827 | } catch { 828 | hoverProps.push({ key: 'color', value: hoverState.color }); 829 | } 830 | } 831 | 832 | if (hoverState.textDecoration) { 833 | hoverProps.push({ key: 'decoration', value: hoverState.textDecoration }); 834 | } 835 | 836 | hoverProps.forEach((prop, propIndex) => { 837 | const isLastProp = propIndex === hoverProps.length - 1; 838 | const propBranch = isLastProp ? '└─' : '├─'; 839 | console.log( 840 | chalk.dim(`│ ${linkIndent} ${propBranch}`) + ' ' + 841 | chalk.dim(`${prop.key}: `) + `${prop.value}` 842 | ); 843 | }); 844 | } 845 | } else { 846 | // Fallback for old format 847 | if (link.textDecoration && link.textDecoration !== 'none') { 848 | console.log(chalk.dim(`│ ${linkIndent}└─`) + ' ' + chalk.dim(`decoration: ${link.textDecoration}`)); 849 | } 850 | } 851 | }); 852 | 853 | if (links.length > 6) { 854 | console.log(chalk.dim('│ └─') + ' ' + chalk.dim(`+${links.length - 6} more`)); 855 | } 856 | 857 | console.log(chalk.dim('│')); 858 | } 859 | 860 | function displayIconSystem(iconSystem) { 861 | if (!iconSystem || iconSystem.length === 0) return; 862 | 863 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Icon System')); 864 | iconSystem.forEach((system, index) => { 865 | const isLast = index === iconSystem.length - 1; 866 | const branch = isLast ? '└─' : '├─'; 867 | const sizes = system.sizes ? ` · ${system.sizes.join(', ')}` : ''; 868 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${system.name} ${chalk.dim(system.type)}${sizes}`); 869 | }); 870 | console.log(chalk.dim('│')); 871 | } 872 | 873 | function displayFrameworks(frameworks) { 874 | if (!frameworks || frameworks.length === 0) return; 875 | 876 | console.log(chalk.dim('├─') + ' ' + chalk.bold('Frameworks')); 877 | frameworks.forEach((fw, index) => { 878 | const isLast = index === frameworks.length - 1; 879 | const branch = isLast ? '└─' : '├─'; 880 | const conf = fw.confidence === 'high' ? chalk.hex('#50FA7B')('●') : chalk.hex('#FFB86C')('●'); 881 | console.log(chalk.dim(`│ ${branch}`) + ' ' + `${conf} ${fw.name} ${chalk.dim(fw.evidence)}`); 882 | }); 883 | console.log(chalk.dim('│')); 884 | } 885 | -------------------------------------------------------------------------------- /lib/extractors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Brand Extraction Engine 3 | * 4 | * Core extraction logic with stealth mode, retry mechanisms, and parallel processing. 5 | * Handles bot detection, SPA hydration, and comprehensive design token extraction. 6 | */ 7 | 8 | import { chromium } from "playwright"; 9 | import chalk from "chalk"; 10 | 11 | /** 12 | * Main extraction function - orchestrates the entire brand analysis process 13 | * 14 | * @param {string} url - Target URL to analyze 15 | * @param {Object} spinner - Ora spinner instance for progress updates 16 | * @param {Object} passedBrowser - Optional pre-configured browser instance 17 | * @param {Object} options - Configuration options (navigationTimeout, etc.) 18 | * @returns {Object} Complete brand extraction data 19 | */ 20 | export async function extractBranding( 21 | url, 22 | spinner, 23 | passedBrowser = null, 24 | options = {} 25 | ) { 26 | const ownBrowser = !passedBrowser; 27 | let browser = passedBrowser; 28 | 29 | // Apply 3x timeout multiplier when --slow flag is enabled 30 | const timeoutMultiplier = options.slow ? 3 : 1; 31 | 32 | // Track timeouts for final report 33 | const timeouts = []; 34 | 35 | if (ownBrowser) { 36 | browser = await chromium.launch({ 37 | headless: true, 38 | args: [ 39 | "--no-sandbox", 40 | "--disable-setuid-sandbox", 41 | "--disable-blink-features=AutomationControlled", 42 | "--disable-web-security", 43 | "--disable-features=IsolateOrigins,site-per-process", 44 | "--disable-dev-shm-usage", 45 | ], 46 | }); 47 | } 48 | 49 | spinner.text = "Creating browser context with stealth mode..."; 50 | const context = await browser.newContext({ 51 | viewport: { width: 1920, height: 1080 }, 52 | userAgent: 53 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", 54 | locale: "en-US", 55 | permissions: ["clipboard-read", "clipboard-write"], 56 | }); 57 | 58 | // Full stealth — kills 99% of Cloudflare bot detection 59 | spinner.text = "Injecting anti-detection scripts..."; 60 | 61 | await context.addInitScript(() => { 62 | Object.defineProperty(navigator, "hardwareConcurrency", { get: () => 8 }); 63 | Object.defineProperty(navigator, "deviceMemory", { get: () => 8 }); 64 | Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); 65 | Object.defineProperty(navigator, "maxTouchPoints", { get: () => 0 }); 66 | 67 | // Spoof Chrome runtime 68 | window.chrome = { 69 | runtime: {}, 70 | loadTimes: () => {}, 71 | csi: () => {}, 72 | app: {}, 73 | }; 74 | 75 | // Remove Playwright traces 76 | delete navigator.__proto__.webdriver; 77 | delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array; 78 | delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise; 79 | delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol; 80 | }); 81 | 82 | const page = await context.newPage(); 83 | 84 | try { 85 | let attempts = 0; 86 | const maxAttempts = 2; 87 | 88 | while (attempts < maxAttempts) { 89 | attempts++; 90 | spinner.text = `Navigating to ${url} (attempt ${attempts}/${maxAttempts})...`; 91 | try { 92 | const initialUrl = url; 93 | await page.goto(url, { 94 | waitUntil: "domcontentloaded", 95 | timeout: (options.navigationTimeout || 20000) * timeoutMultiplier, 96 | }); 97 | const finalUrl = page.url(); 98 | 99 | // Check for redirects or domain changes 100 | if (initialUrl !== finalUrl) { 101 | spinner.stop(); 102 | const initialDomain = new URL(initialUrl).hostname; 103 | const finalDomain = new URL(finalUrl).hostname; 104 | 105 | if (initialDomain !== finalDomain) { 106 | console.log( 107 | chalk.hex('#FFB86C')(` ⚠ Page redirected to different domain:`) 108 | ); 109 | console.log(chalk.dim(` From: ${initialUrl}`)); 110 | console.log(chalk.dim(` To: ${finalUrl}`)); 111 | } else { 112 | console.log(chalk.hex('#8BE9FD')(` ℹ Page redirected within same domain:`)); 113 | console.log(chalk.dim(` From: ${initialUrl}`)); 114 | console.log(chalk.dim(` To: ${finalUrl}`)); 115 | } 116 | spinner.start(); 117 | } 118 | 119 | spinner.stop(); 120 | console.log(chalk.hex('#50FA7B')(` ✓ Page loaded`)); 121 | 122 | // Give SPAs time to hydrate (Linear, Figma, Notion, etc.) 123 | spinner.start("Waiting for SPA hydration..."); 124 | const hydrationTime = 8000 * timeoutMultiplier; 125 | await page.waitForTimeout(hydrationTime); 126 | spinner.stop(); 127 | console.log(chalk.hex('#50FA7B')(` ✓ Hydration complete (${hydrationTime/1000}s)`)); 128 | 129 | // Optional: wait for main content 130 | spinner.start("Waiting for main content..."); 131 | try { 132 | await page.waitForSelector("main, header, [data-hero], section", { 133 | timeout: 10000 * timeoutMultiplier, 134 | }); 135 | spinner.stop(); 136 | console.log(chalk.hex('#50FA7B')(` ✓ Main content detected`)); 137 | } catch { 138 | spinner.stop(); 139 | console.log(chalk.hex('#FFB86C')(` ⚠ Main content selector timeout (continuing)`)); 140 | timeouts.push('Main content selector'); 141 | } 142 | 143 | // Simulate human behavior 144 | spinner.start("Simulating human interaction..."); 145 | await page.mouse.move( 146 | 300 + Math.random() * 400, 147 | 200 + Math.random() * 300 148 | ); 149 | await page.evaluate(() => window.scrollTo(0, 400)); 150 | spinner.stop(); 151 | console.log(chalk.hex('#50FA7B')(` ✓ Human behavior simulated`)); 152 | 153 | // Final hydration wait 154 | spinner.start("Final content stabilization..."); 155 | const stabilizationTime = 4000 * timeoutMultiplier; 156 | await page.waitForTimeout(stabilizationTime); 157 | spinner.stop(); 158 | console.log(chalk.hex('#50FA7B')(` ✓ Page fully loaded and stable`)); 159 | 160 | spinner.start("Validating page content..."); 161 | const contentLength = await page.evaluate( 162 | () => document.body.textContent.length 163 | ); 164 | spinner.stop(); 165 | console.log(chalk.hex('#50FA7B')(` ✓ Content validated: ${contentLength} chars`)); 166 | 167 | if (contentLength > 100) break; 168 | 169 | spinner.warn( 170 | `Page seems empty (attempt ${attempts}/${maxAttempts}), retrying...` 171 | ); 172 | console.log( 173 | chalk.hex('#FFB86C')( 174 | ` ⚠ Content length: ${contentLength} chars (expected >100)` 175 | ) 176 | ); 177 | await page.waitForTimeout(3000 * timeoutMultiplier); 178 | } catch (err) { 179 | if (attempts >= maxAttempts) { 180 | console.error(` ↳ Failed after ${maxAttempts} attempts`); 181 | console.error(` ↳ Last error: ${err.message}`); 182 | console.error(` ↳ URL: ${url}`); 183 | throw err; 184 | } 185 | spinner.warn( 186 | `Navigation failed (attempt ${attempts}/${maxAttempts}), retrying...` 187 | ); 188 | console.log(` ↳ Error: ${err.message}`); 189 | await page.waitForTimeout(3000 * timeoutMultiplier); 190 | } 191 | } 192 | 193 | spinner.stop(); 194 | console.log(chalk.hex('#8BE9FD')("\n Extracting design tokens...\n")); 195 | 196 | spinner.start("Extracting logo and favicons..."); 197 | const { logo, favicons } = await extractLogo(page, url); 198 | spinner.stop(); 199 | console.log(chalk.hex('#50FA7B')(` ✓ Logo and favicons extracted`)); 200 | 201 | spinner.start("Analyzing design system (12 parallel tasks)..."); 202 | const [ 203 | colors, 204 | typography, 205 | spacing, 206 | borderRadius, 207 | borders, 208 | shadows, 209 | buttons, 210 | inputs, 211 | links, 212 | breakpoints, 213 | iconSystem, 214 | frameworks, 215 | ] = await Promise.all([ 216 | extractColors(page), 217 | extractTypography(page), 218 | extractSpacing(page), 219 | extractBorderRadius(page), 220 | extractBorders(page), 221 | extractShadows(page), 222 | extractButtonStyles(page), 223 | extractInputStyles(page), 224 | extractLinkStyles(page), 225 | extractBreakpoints(page), 226 | detectIconSystem(page), 227 | detectFrameworks(page), 228 | ]); 229 | 230 | spinner.stop(); 231 | console.log(colors.palette.length > 0 ? chalk.hex('#50FA7B')(` ✓ Colors: ${colors.palette.length} found`) : chalk.hex('#FFB86C')(` ⚠ Colors: 0 found`)); 232 | console.log(typography.styles.length > 0 ? chalk.hex('#50FA7B')(` ✓ Typography: ${typography.styles.length} styles`) : chalk.hex('#FFB86C')(` ⚠ Typography: 0 styles`)); 233 | console.log(spacing.commonValues.length > 0 ? chalk.hex('#50FA7B')(` ✓ Spacing: ${spacing.commonValues.length} values`) : chalk.hex('#FFB86C')(` ⚠ Spacing: 0 values`)); 234 | console.log(borderRadius.values.length > 0 ? chalk.hex('#50FA7B')(` ✓ Border radius: ${borderRadius.values.length} values`) : chalk.hex('#FFB86C')(` ⚠ Border radius: 0 values`)); 235 | 236 | const bordersTotal = (borders?.widths?.length || 0) + (borders?.styles?.length || 0) + (borders?.colors?.length || 0); 237 | console.log(bordersTotal > 0 ? 238 | chalk.hex('#50FA7B')(` ✓ Borders: ${borders?.widths?.length || 0} widths, ${borders?.styles?.length || 0} styles, ${borders?.colors?.length || 0} colors`) : 239 | chalk.hex('#FFB86C')(` ⚠ Borders: 0 found`)); 240 | 241 | console.log(shadows.length > 0 ? chalk.hex('#50FA7B')(` ✓ Shadows: ${shadows.length} found`) : chalk.hex('#FFB86C')(` ⚠ Shadows: 0 found`)); 242 | console.log(buttons.length > 0 ? chalk.hex('#50FA7B')(` ✓ Buttons: ${buttons.length} variants`) : chalk.hex('#FFB86C')(` ⚠ Buttons: 0 variants`)); 243 | console.log(inputs.length > 0 ? chalk.hex('#50FA7B')(` ✓ Inputs: ${inputs.length} styles`) : chalk.hex('#FFB86C')(` ⚠ Inputs: 0 styles`)); 244 | console.log(links.length > 0 ? chalk.hex('#50FA7B')(` ✓ Links: ${links.length} styles`) : chalk.hex('#FFB86C')(` ⚠ Links: 0 styles`)); 245 | console.log(breakpoints.length > 0 ? chalk.hex('#50FA7B')(` ✓ Breakpoints: ${breakpoints.length} detected`) : chalk.hex('#FFB86C')(` ⚠ Breakpoints: 0 detected`)); 246 | console.log(iconSystem.length > 0 ? chalk.hex('#50FA7B')(` ✓ Icon systems: ${iconSystem.length} detected`) : chalk.hex('#FFB86C')(` ⚠ Icon systems: 0 detected`)); 247 | console.log(frameworks.length > 0 ? chalk.hex('#50FA7B')(` ✓ Frameworks: ${frameworks.length} detected`) : chalk.hex('#FFB86C')(` ⚠ Frameworks: 0 detected`)); 248 | console.log(); 249 | 250 | // Extract hover/focus state colors using actual interaction simulation 251 | spinner.start("Extracting hover/focus state colors..."); 252 | const hoverFocusColors = []; 253 | 254 | // Helper: Split multi-value color strings (e.g., "rgb(0,0,0) rgb(255,255,255) rgb(28,105,212)") 255 | function splitMultiValueColors(colorValue) { 256 | if (!colorValue) return []; 257 | 258 | // Match all rgb/rgba/hsl/hsla/hex values in the string 259 | const colorRegex = /(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\))/gi; 260 | const matches = colorValue.match(colorRegex) || [colorValue]; 261 | 262 | // Filter out invalid matches 263 | return matches.filter(c => 264 | c !== 'transparent' && 265 | c !== 'rgba(0, 0, 0, 0)' && 266 | c !== 'rgba(0,0,0,0)' && 267 | c.length > 3 268 | ); 269 | } 270 | 271 | // Get all interactive elements 272 | const interactiveElements = await page.$$(` 273 | a, 274 | button, 275 | input, 276 | textarea, 277 | select, 278 | [role="button"], 279 | [role="link"], 280 | [role="tab"], 281 | [role="menuitem"], 282 | [role="switch"], 283 | [role="checkbox"], 284 | [role="radio"], 285 | [role="textbox"], 286 | [role="searchbox"], 287 | [role="combobox"], 288 | [aria-pressed], 289 | [aria-expanded], 290 | [aria-current], 291 | [tabindex]:not([tabindex="-1"]) 292 | `); 293 | 294 | // Sample up to 20 elements for performance 295 | const sampled = interactiveElements.slice(0, 20); 296 | 297 | for (const element of sampled) { 298 | try { 299 | // Check if element is visible 300 | const isVisible = await element.evaluate(el => { 301 | const rect = el.getBoundingClientRect(); 302 | const style = getComputedStyle(el); 303 | return rect.width > 0 && 304 | rect.height > 0 && 305 | style.display !== 'none' && 306 | style.visibility !== 'hidden' && 307 | style.opacity !== '0'; 308 | }); 309 | 310 | if (!isVisible) continue; 311 | 312 | // Get initial state colors 313 | const beforeState = await element.evaluate(el => { 314 | const computed = getComputedStyle(el); 315 | return { 316 | color: computed.color, 317 | backgroundColor: computed.backgroundColor, 318 | borderColor: computed.borderColor, 319 | tag: el.tagName.toLowerCase() 320 | }; 321 | }); 322 | 323 | // Hover over element 324 | await element.hover({ timeout: 1000 * timeoutMultiplier }).catch(() => {}); 325 | await page.waitForTimeout(100 * timeoutMultiplier); // Wait for transitions 326 | 327 | // Get hover state colors 328 | const afterHover = await element.evaluate(el => { 329 | const computed = getComputedStyle(el); 330 | return { 331 | color: computed.color, 332 | backgroundColor: computed.backgroundColor, 333 | borderColor: computed.borderColor 334 | }; 335 | }); 336 | 337 | // Compare and collect changed colors 338 | if (afterHover.color !== beforeState.color && 339 | afterHover.color !== 'rgba(0, 0, 0, 0)' && 340 | afterHover.color !== 'transparent') { 341 | hoverFocusColors.push({ 342 | color: afterHover.color, 343 | property: 'color', 344 | state: 'hover', 345 | element: beforeState.tag 346 | }); 347 | } 348 | 349 | if (afterHover.backgroundColor !== beforeState.backgroundColor && 350 | afterHover.backgroundColor !== 'rgba(0, 0, 0, 0)' && 351 | afterHover.backgroundColor !== 'transparent') { 352 | hoverFocusColors.push({ 353 | color: afterHover.backgroundColor, 354 | property: 'background-color', 355 | state: 'hover', 356 | element: beforeState.tag 357 | }); 358 | } 359 | 360 | if (afterHover.borderColor !== beforeState.borderColor) { 361 | // Split multi-value border colors 362 | const hoverBorderColors = splitMultiValueColors(afterHover.borderColor); 363 | const beforeBorderColors = splitMultiValueColors(beforeState.borderColor); 364 | 365 | hoverBorderColors.forEach(color => { 366 | if (!beforeBorderColors.includes(color)) { 367 | hoverFocusColors.push({ 368 | color: color, 369 | property: 'border-color', 370 | state: 'hover', 371 | element: beforeState.tag 372 | }); 373 | } 374 | }); 375 | } 376 | 377 | // Try focus for inputs/buttons 378 | if (['input', 'textarea', 'select', 'button'].includes(beforeState.tag)) { 379 | try { 380 | await element.focus({ timeout: 500 * timeoutMultiplier }); 381 | await page.waitForTimeout(100 * timeoutMultiplier); 382 | 383 | const afterFocus = await element.evaluate(el => { 384 | const computed = getComputedStyle(el); 385 | return { 386 | color: computed.color, 387 | backgroundColor: computed.backgroundColor, 388 | borderColor: computed.borderColor, 389 | outlineColor: computed.outlineColor 390 | }; 391 | }); 392 | 393 | // Check for focus-specific changes 394 | if (afterFocus.outlineColor && 395 | afterFocus.outlineColor !== 'rgba(0, 0, 0, 0)' && 396 | afterFocus.outlineColor !== 'transparent' && 397 | afterFocus.outlineColor !== beforeState.color) { 398 | hoverFocusColors.push({ 399 | color: afterFocus.outlineColor, 400 | property: 'outline-color', 401 | state: 'focus', 402 | element: beforeState.tag 403 | }); 404 | } 405 | 406 | if (afterFocus.borderColor !== beforeState.borderColor && 407 | afterFocus.borderColor !== afterHover.borderColor) { 408 | // Split multi-value border colors 409 | const focusBorderColors = splitMultiValueColors(afterFocus.borderColor); 410 | const beforeBorderColors = splitMultiValueColors(beforeState.borderColor); 411 | 412 | focusBorderColors.forEach(color => { 413 | if (!beforeBorderColors.includes(color)) { 414 | hoverFocusColors.push({ 415 | color: color, 416 | property: 'border-color', 417 | state: 'focus', 418 | element: beforeState.tag 419 | }); 420 | } 421 | }); 422 | } 423 | } catch (e) { 424 | // Focus might fail, continue 425 | } 426 | } 427 | 428 | } catch (e) { 429 | // Element might be stale or not interactable, continue 430 | } 431 | } 432 | 433 | // Move mouse away to reset hover states 434 | await page.mouse.move(0, 0).catch(() => {}); 435 | 436 | // Merge hover/focus colors into palette 437 | hoverFocusColors.forEach(({ color }) => { 438 | const isDuplicate = colors.palette.some((c) => c.color === color); 439 | if (!isDuplicate && color) { 440 | // Normalize and add to palette 441 | const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); 442 | let normalized = color.toLowerCase(); 443 | if (rgbaMatch) { 444 | const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0"); 445 | const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0"); 446 | const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0"); 447 | normalized = `#${r}${g}${b}`; 448 | } 449 | 450 | colors.palette.push({ 451 | color, 452 | normalized, 453 | count: 1, 454 | confidence: "medium", 455 | sources: ["hover/focus"], 456 | }); 457 | } 458 | }); 459 | 460 | spinner.stop(); 461 | console.log(hoverFocusColors.length > 0 ? 462 | chalk.hex('#50FA7B')(` ✓ Hover/focus: ${hoverFocusColors.length} state colors found`) : 463 | chalk.hex('#FFB86C')(` ⚠ Hover/focus: 0 state colors found`)); 464 | 465 | // Extract additional colors from dark mode if requested 466 | if (options.darkMode) { 467 | spinner.start("Extracting dark mode colors..."); 468 | 469 | // Try multiple methods to enable dark mode 470 | await page.evaluate(() => { 471 | // Method 1: Add data-theme attribute 472 | document.documentElement.setAttribute("data-theme", "dark"); 473 | document.documentElement.setAttribute("data-mode", "dark"); 474 | document.body.setAttribute("data-theme", "dark"); 475 | 476 | // Method 2: Add dark mode classes 477 | document.documentElement.classList.add( 478 | "dark", 479 | "dark-mode", 480 | "theme-dark" 481 | ); 482 | document.body.classList.add("dark", "dark-mode", "theme-dark"); 483 | 484 | // Method 3: Trigger prefers-color-scheme media query 485 | // (Playwright can emulate this, but let's also try programmatically) 486 | }); 487 | 488 | // Emulate prefers-color-scheme: dark 489 | await page.emulateMedia({ colorScheme: "dark" }); 490 | 491 | // Wait for transitions to complete 492 | await page.waitForTimeout(500 * timeoutMultiplier); 493 | 494 | const darkModeColors = await extractColors(page); 495 | const darkModeButtons = await extractButtonStyles(page); 496 | const darkModeLinks = await extractLinkStyles(page); 497 | 498 | // Merge dark mode colors into main palette 499 | const mergedPalette = [...colors.palette]; 500 | darkModeColors.palette.forEach((darkColor) => { 501 | // Check if this color is already in the palette using perceptual similarity 502 | const isDuplicate = mergedPalette.some((existingColor) => { 503 | // Simple check - could use delta-E here too 504 | return existingColor.normalized === darkColor.normalized; 505 | }); 506 | 507 | if (!isDuplicate) { 508 | mergedPalette.push({ ...darkColor, source: "dark-mode" }); 509 | } 510 | }); 511 | 512 | colors.palette = mergedPalette; 513 | 514 | // Merge semantic colors 515 | Object.assign(colors.semantic, darkModeColors.semantic); 516 | 517 | // Merge dark mode buttons and links 518 | buttons.push( 519 | ...darkModeButtons.map((btn) => ({ ...btn, source: "dark-mode" })) 520 | ); 521 | links.push( 522 | ...darkModeLinks.map((link) => ({ ...link, source: "dark-mode" })) 523 | ); 524 | 525 | spinner.stop(); 526 | console.log(chalk.hex('#50FA7B')(` ✓ Dark mode: +${darkModeColors.palette.length} colors`)); 527 | } 528 | 529 | // Extract additional colors from mobile viewport if requested 530 | if (options.mobile) { 531 | spinner.start("Extracting mobile viewport colors..."); 532 | 533 | // Change viewport to mobile 534 | await page.setViewportSize({ width: 375, height: 667 }); 535 | 536 | // Wait for responsive changes 537 | await page.waitForTimeout(500 * timeoutMultiplier); 538 | 539 | const mobileColors = await extractColors(page); 540 | 541 | // Merge mobile colors into main palette 542 | const mergedPalette = [...colors.palette]; 543 | mobileColors.palette.forEach((mobileColor) => { 544 | const isDuplicate = mergedPalette.some((existingColor) => { 545 | return existingColor.normalized === mobileColor.normalized; 546 | }); 547 | 548 | if (!isDuplicate) { 549 | mergedPalette.push({ ...mobileColor, source: "mobile" }); 550 | } 551 | }); 552 | 553 | colors.palette = mergedPalette; 554 | 555 | spinner.stop(); 556 | console.log(chalk.hex('#50FA7B')(` ✓ Mobile: +${mobileColors.palette.length} colors`)); 557 | } 558 | 559 | spinner.stop(); 560 | console.log(); 561 | console.log(chalk.hex('#50FA7B').bold("✔ Brand extraction complete!")); 562 | 563 | // Report timeouts and suggest --slow if needed 564 | if (timeouts.length > 0 && !options.slow) { 565 | console.log(); 566 | console.log(chalk.hex('#FFB86C')(`⚠ ${timeouts.length} timeout(s) occurred during extraction:`)); 567 | timeouts.forEach(t => console.log(chalk.dim(` • ${t}`))); 568 | console.log(); 569 | console.log(chalk.hex('#8BE9FD')(`💡 Tip: Try running with ${chalk.bold('--slow')} flag for more reliable results on slow-loading sites`)); 570 | } 571 | 572 | const result = { 573 | url: page.url(), 574 | extractedAt: new Date().toISOString(), 575 | logo, 576 | favicons, 577 | colors, 578 | typography, 579 | spacing, 580 | borderRadius, 581 | borders, 582 | shadows, 583 | components: { buttons, inputs, links }, 584 | breakpoints, 585 | iconSystem, 586 | frameworks, 587 | }; 588 | 589 | // Detect canvas-only / WebGL sites (Tesla, Apple Vision Pro, etc.) 590 | const isCanvasOnly = await page.evaluate(() => { 591 | const canvases = document.querySelectorAll("canvas"); 592 | const hasRealContent = document.body.textContent.trim().length > 200; 593 | const hasManyCanvases = canvases.length > 3; 594 | const hasWebGL = Array.from(canvases).some((c) => { 595 | const ctx = c.getContext("webgl") || c.getContext("webgl2"); 596 | return !!ctx; 597 | }); 598 | return hasManyCanvases && hasWebGL && !hasRealContent; 599 | }); 600 | 601 | if (isCanvasOnly) { 602 | result.note = 603 | "This website uses canvas/WebGL rendering (e.g. Tesla, Apple Vision Pro). Design system cannot be extracted from DOM."; 604 | result.isCanvasOnly = true; 605 | } 606 | 607 | if (ownBrowser) await browser.close(); 608 | 609 | return result; 610 | } catch (error) { 611 | if (ownBrowser) await browser.close(); 612 | spinner.fail("Extraction failed"); 613 | console.error(` ↳ Error during extraction: ${error.message}`); 614 | console.error(` ↳ URL: ${url}`); 615 | console.error(` ↳ Stage: ${spinner.text || "unknown"}`); 616 | throw error; 617 | } 618 | } 619 | 620 | /** 621 | * Extract logo information from the page 622 | * Looks for common logo patterns: img with logo in class/id, SVG logos, etc. 623 | * Includes safe zone estimation and favicon detection 624 | */ 625 | async function extractLogo(page, url) { 626 | return await page.evaluate((baseUrl) => { 627 | // Find logo - check img, svg, and svg elements containing with logo references 628 | const candidates = Array.from(document.querySelectorAll("img, svg")).filter( 629 | (el) => { 630 | const className = 631 | typeof el.className === "string" 632 | ? el.className 633 | : el.className.baseVal || ""; 634 | const attrs = ( 635 | className + 636 | " " + 637 | (el.id || "") + 638 | " " + 639 | (el.getAttribute("alt") || "") 640 | ).toLowerCase(); 641 | 642 | // Check element's own attributes 643 | if (attrs.includes("logo") || attrs.includes("brand")) { 644 | return true; 645 | } 646 | 647 | // For SVG elements, also check children for logo references 648 | if (el.tagName === "svg" || el.tagName === "SVG") { 649 | const useElements = el.querySelectorAll("use"); 650 | for (const use of useElements) { 651 | const href = 652 | use.getAttribute("href") || use.getAttribute("xlink:href") || ""; 653 | if ( 654 | href.toLowerCase().includes("logo") || 655 | href.toLowerCase().includes("brand") 656 | ) { 657 | return true; 658 | } 659 | } 660 | } 661 | 662 | return false; 663 | } 664 | ); 665 | 666 | let logoData = null; 667 | if (candidates.length > 0) { 668 | const logo = candidates[0]; 669 | const computed = window.getComputedStyle(logo); 670 | const parent = logo.parentElement; 671 | const parentComputed = parent ? window.getComputedStyle(parent) : null; 672 | 673 | // Calculate safe zone from padding and margins 674 | const safeZone = { 675 | top: 676 | parseFloat(computed.marginTop) + 677 | (parentComputed ? parseFloat(parentComputed.paddingTop) : 0), 678 | right: 679 | parseFloat(computed.marginRight) + 680 | (parentComputed ? parseFloat(parentComputed.paddingRight) : 0), 681 | bottom: 682 | parseFloat(computed.marginBottom) + 683 | (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0), 684 | left: 685 | parseFloat(computed.marginLeft) + 686 | (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0), 687 | }; 688 | 689 | if (logo.tagName === "IMG") { 690 | logoData = { 691 | source: "img", 692 | url: new URL(logo.src, baseUrl).href, 693 | width: logo.naturalWidth || logo.width, 694 | height: logo.naturalHeight || logo.height, 695 | alt: logo.alt, 696 | safeZone: safeZone, 697 | }; 698 | } else { 699 | // SVG logo - try to get the parent link or closest anchor 700 | const parentLink = logo.closest("a"); 701 | logoData = { 702 | source: "svg", 703 | url: parentLink ? parentLink.href : window.location.href, 704 | width: logo.width?.baseVal?.value, 705 | height: logo.height?.baseVal?.value, 706 | safeZone: safeZone, 707 | }; 708 | } 709 | } 710 | 711 | // Extract all favicons 712 | const favicons = []; 713 | 714 | // Standard favicons 715 | document.querySelectorAll('link[rel*="icon"]').forEach((link) => { 716 | const href = link.getAttribute("href"); 717 | if (href) { 718 | favicons.push({ 719 | type: link.getAttribute("rel"), 720 | url: new URL(href, baseUrl).href, 721 | sizes: link.getAttribute("sizes") || null, 722 | }); 723 | } 724 | }); 725 | 726 | // Apple touch icons 727 | document 728 | .querySelectorAll('link[rel="apple-touch-icon"]') 729 | .forEach((link) => { 730 | const href = link.getAttribute("href"); 731 | if (href) { 732 | favicons.push({ 733 | type: "apple-touch-icon", 734 | url: new URL(href, baseUrl).href, 735 | sizes: link.getAttribute("sizes") || null, 736 | }); 737 | } 738 | }); 739 | 740 | // Open Graph image 741 | const ogImage = document.querySelector('meta[property="og:image"]'); 742 | if (ogImage) { 743 | const content = ogImage.getAttribute("content"); 744 | if (content) { 745 | favicons.push({ 746 | type: "og:image", 747 | url: new URL(content, baseUrl).href, 748 | sizes: null, 749 | }); 750 | } 751 | } 752 | 753 | // Twitter card image 754 | const twitterImage = document.querySelector('meta[name="twitter:image"]'); 755 | if (twitterImage) { 756 | const content = twitterImage.getAttribute("content"); 757 | if (content) { 758 | favicons.push({ 759 | type: "twitter:image", 760 | url: new URL(content, baseUrl).href, 761 | sizes: null, 762 | }); 763 | } 764 | } 765 | 766 | // Check for default /favicon.ico if not already included 767 | const hasFaviconIco = favicons.some((f) => f.url.endsWith("/favicon.ico")); 768 | if (!hasFaviconIco) { 769 | favicons.push({ 770 | type: "favicon.ico", 771 | url: new URL("/favicon.ico", baseUrl).href, 772 | sizes: null, 773 | }); 774 | } 775 | 776 | return { 777 | logo: logoData, 778 | favicons: favicons, 779 | }; 780 | }, url); 781 | } 782 | 783 | /** 784 | * Extract color palette with confidence scoring 785 | * Analyzes semantic colors, CSS variables, and visual frequency 786 | */ 787 | async function extractColors(page) { 788 | return await page.evaluate(() => { 789 | // Helper: Convert any color to normalized hex for deduplication 790 | function normalizeColor(color) { 791 | const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); 792 | if (rgbaMatch) { 793 | const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0"); 794 | const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0"); 795 | const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0"); 796 | return `#${r}${g}${b}`; 797 | } 798 | return color.toLowerCase(); 799 | } 800 | 801 | // Helper: Check if value is a valid simple color (not calc/clamp/var) 802 | function isValidColorValue(value) { 803 | if (!value) return false; 804 | // Reject CSS functions unless they contain actual color values 805 | if ( 806 | value.includes("calc(") || 807 | value.includes("clamp(") || 808 | value.includes("var(") 809 | ) { 810 | // Only accept if it contains rgb/hsl/# inside 811 | return /#[0-9a-f]{3,6}|rgba?\(|hsla?\(/i.test(value); 812 | } 813 | // Accept hex, rgb, hsl, named colors 814 | return /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|[a-z]+)/i.test(value); 815 | } 816 | 817 | const colorMap = new Map(); // Normalized color -> original representations 818 | const semanticColors = {}; 819 | const cssVariables = {}; 820 | 821 | // Extract CSS variables - filter out framework presets 822 | const styles = getComputedStyle(document.documentElement); 823 | const domain = window.location.hostname; 824 | 825 | for (let i = 0; i < styles.length; i++) { 826 | const prop = styles[i]; 827 | if (prop.startsWith("--")) { 828 | // Skip WordPress presets (they're almost never customized) 829 | if (prop.startsWith("--wp--preset")) { 830 | continue; 831 | } 832 | 833 | // Skip obvious system/default variables 834 | if (prop.includes("--system-") || prop.includes("--default-")) { 835 | continue; 836 | } 837 | 838 | // Skip cookie consent unless it's a cookie consent domain 839 | if ( 840 | prop.includes("--cc-") && 841 | !domain.includes("cookie") && 842 | !domain.includes("consent") 843 | ) { 844 | continue; 845 | } 846 | 847 | // For other frameworks, allow them through - brands often customize these 848 | // --tw-* (Tailwind), --bs-* (Bootstrap), --mdc-* (Material), --chakra-* are OK 849 | 850 | const value = styles.getPropertyValue(prop).trim(); 851 | 852 | // Skip SCSS functions and invalid values 853 | if ( 854 | value.includes("color.adjust(") || 855 | value.includes("rgba(0, 0, 0, 0)") || 856 | value.includes("rgba(0,0,0,0)") || 857 | value.includes("lighten(") || 858 | value.includes("darken(") || 859 | value.includes("saturate(") 860 | ) { 861 | continue; 862 | } 863 | 864 | // Only include valid color values 865 | if ( 866 | isValidColorValue(value) && 867 | (prop.includes("color") || 868 | prop.includes("bg") || 869 | prop.includes("text") || 870 | prop.includes("brand")) 871 | ) { 872 | cssVariables[prop] = value; 873 | } 874 | } 875 | } 876 | 877 | // Count total visible elements for threshold calculation 878 | const elements = document.querySelectorAll("*"); 879 | const totalElements = elements.length; 880 | 881 | const contextScores = { 882 | logo: 5, 883 | brand: 5, 884 | primary: 4, 885 | cta: 4, 886 | hero: 3, 887 | button: 3, 888 | link: 2, 889 | header: 2, 890 | nav: 1, 891 | }; 892 | 893 | elements.forEach((el) => { 894 | // Skip hidden elements 895 | const computed = getComputedStyle(el); 896 | if ( 897 | computed.display === "none" || 898 | computed.visibility === "hidden" || 899 | computed.opacity === "0" 900 | ) { 901 | return; 902 | } 903 | 904 | const bgColor = computed.backgroundColor; 905 | const textColor = computed.color; 906 | const borderColor = computed.borderColor; 907 | 908 | // Build comprehensive context string including data attributes 909 | const context = ( 910 | el.className + " " + 911 | el.id + " " + 912 | (el.getAttribute('data-tracking-linkid') || '') + " " + 913 | (el.getAttribute('data-cta') || '') + " " + 914 | (el.getAttribute('data-component') || '') + " " + 915 | el.tagName 916 | ).toLowerCase(); 917 | 918 | let score = 1; 919 | 920 | // Check for semantic context keywords 921 | for (const [keyword, weight] of Object.entries(contextScores)) { 922 | if (context.includes(keyword)) score = Math.max(score, weight); 923 | } 924 | 925 | // Extra boost for colored buttons (non-transparent, non-white, non-black backgrounds) 926 | if ((context.includes('button') || context.includes('btn') || context.includes('cta')) && 927 | bgColor && 928 | bgColor !== 'rgba(0, 0, 0, 0)' && 929 | bgColor !== 'transparent' && 930 | bgColor !== 'rgb(255, 255, 255)' && 931 | bgColor !== 'rgb(0, 0, 0)' && 932 | bgColor !== 'rgb(239, 239, 239)') { // Also skip very light gray 933 | score = Math.max(score, 25); // Colored buttons get HIGH confidence (>20) 934 | } 935 | 936 | // Helper: Split multi-value color strings (e.g., "rgb(0,0,0) rgb(255,255,255) rgb(28,105,212)") 937 | function extractColorsFromValue(colorValue) { 938 | if (!colorValue) return []; 939 | 940 | // Match all rgb/rgba/hsl/hsla/hex values in the string 941 | const colorRegex = /(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+)/gi; 942 | const matches = colorValue.match(colorRegex) || []; 943 | 944 | // Filter out invalid matches 945 | return matches.filter(c => 946 | c !== 'transparent' && 947 | c !== 'rgba(0, 0, 0, 0)' && 948 | c !== 'rgba(0,0,0,0)' && 949 | c.length > 2 950 | ); 951 | } 952 | 953 | // Collect all colors from this element 954 | const allColors = [ 955 | ...extractColorsFromValue(bgColor), 956 | ...extractColorsFromValue(textColor), 957 | ...extractColorsFromValue(borderColor) 958 | ]; 959 | 960 | allColors.forEach((color) => { 961 | if (color && color !== "rgba(0, 0, 0, 0)" && color !== "transparent") { 962 | const normalized = normalizeColor(color); 963 | const existing = colorMap.get(normalized) || { 964 | original: color, // Keep first seen format 965 | count: 0, 966 | score: 0, 967 | sources: new Set(), 968 | }; 969 | existing.count++; 970 | existing.score += score; 971 | if (score > 1) { 972 | const source = context.split(" ")[0].substring(0, 30); // Limit source length 973 | if (source && !source.includes("__")) { 974 | // Skip auto-generated class names 975 | existing.sources.add(source); 976 | } 977 | } 978 | colorMap.set(normalized, existing); 979 | } 980 | }); 981 | 982 | // Semantic color detection 983 | if (context.includes("primary") || el.matches('[class*="primary"]')) { 984 | semanticColors.primary = 985 | bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent" 986 | ? bgColor 987 | : textColor; 988 | } 989 | if (context.includes("secondary")) { 990 | semanticColors.secondary = bgColor; 991 | } 992 | }); 993 | 994 | // Calculate threshold: 1% of elements or minimum 3 occurrences 995 | const threshold = Math.max(3, Math.floor(totalElements * 0.01)); 996 | 997 | // Helper: Check if a color is "structural" (used on >40% of elements) 998 | // Only filter blacks/whites/grays if they're clearly just scaffolding 999 | function isStructuralColor(data, totalElements) { 1000 | const usagePercent = (data.count / totalElements) * 100; 1001 | const normalized = normalizeColor(data.original); 1002 | 1003 | // Pure transparent - always structural 1004 | if ( 1005 | data.original === "rgba(0, 0, 0, 0)" || 1006 | data.original === "transparent" 1007 | ) { 1008 | return true; 1009 | } 1010 | 1011 | // If a color is used on >40% of elements AND has very low semantic score, it's structural 1012 | if (usagePercent > 40 && data.score < data.count * 1.2) { 1013 | return true; 1014 | } 1015 | 1016 | return false; 1017 | } 1018 | 1019 | // Helper: Calculate delta-E color distance (simplified CIE76) 1020 | function deltaE(rgb1, rgb2) { 1021 | // Convert hex to RGB if needed 1022 | function hexToRgb(hex) { 1023 | if (!hex.startsWith("#")) return null; 1024 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 1025 | return result 1026 | ? { 1027 | r: parseInt(result[1], 16), 1028 | g: parseInt(result[2], 16), 1029 | b: parseInt(result[3], 16), 1030 | } 1031 | : null; 1032 | } 1033 | 1034 | const c1 = hexToRgb(rgb1); 1035 | const c2 = hexToRgb(rgb2); 1036 | 1037 | if (!c1 || !c2) return 999; // Very different if can't parse 1038 | 1039 | // Simple RGB distance (not true delta-E but good enough) 1040 | const rDiff = c1.r - c2.r; 1041 | const gDiff = c1.g - c2.g; 1042 | const bDiff = c1.b - c2.b; 1043 | 1044 | return Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); 1045 | } 1046 | 1047 | const palette = Array.from(colorMap.entries()) 1048 | .filter(([normalizedColor, data]) => { 1049 | // Filter out colors below threshold 1050 | if (data.count < threshold) return false; 1051 | 1052 | // Filter out structural colors (very high usage without semantic context) 1053 | if (isStructuralColor(data, totalElements)) { 1054 | return false; 1055 | } 1056 | 1057 | return true; 1058 | }) 1059 | .map(([normalizedColor, data]) => ({ 1060 | color: data.original, 1061 | normalized: normalizedColor, 1062 | count: data.count, 1063 | confidence: 1064 | data.score > 20 ? "high" : data.score > 5 ? "medium" : "low", 1065 | sources: Array.from(data.sources).slice(0, 3), 1066 | })) 1067 | .sort((a, b) => b.count - a.count); 1068 | 1069 | // Apply perceptual deduplication using delta-E 1070 | // Merge colors that are visually very similar (delta-E < 15) 1071 | const perceptuallyDeduped = []; 1072 | const merged = new Set(); 1073 | 1074 | palette.forEach((color, index) => { 1075 | if (merged.has(index)) return; 1076 | 1077 | // Find all similar colors 1078 | const similar = [color]; 1079 | for (let i = index + 1; i < palette.length; i++) { 1080 | if (merged.has(i)) continue; 1081 | 1082 | const distance = deltaE(color.normalized, palette[i].normalized); 1083 | if (distance < 15) { 1084 | // Threshold for "visually similar" 1085 | similar.push(palette[i]); 1086 | merged.add(i); 1087 | } 1088 | } 1089 | 1090 | // Keep the one with highest count (most common variant) 1091 | const best = similar.sort((a, b) => b.count - a.count)[0]; 1092 | perceptuallyDeduped.push(best); 1093 | }); 1094 | 1095 | // Deduplicate and filter CSS variables 1096 | const paletteNormalizedColors = new Set( 1097 | perceptuallyDeduped.map((c) => c.normalized) 1098 | ); 1099 | const cssVarsByColor = new Map(); // normalized color -> variable names 1100 | 1101 | Object.entries(cssVariables).forEach(([prop, value]) => { 1102 | const normalized = normalizeColor(value); 1103 | 1104 | // Skip if this color is already in the palette 1105 | if (paletteNormalizedColors.has(normalized)) { 1106 | return; 1107 | } 1108 | 1109 | // Apply perceptual deduplication to CSS variables too 1110 | let isDuplicate = false; 1111 | for (const paletteColor of perceptuallyDeduped) { 1112 | if (deltaE(normalized, paletteColor.normalized) < 15) { 1113 | isDuplicate = true; 1114 | break; 1115 | } 1116 | } 1117 | if (isDuplicate) return; 1118 | 1119 | if (!cssVarsByColor.has(normalized)) { 1120 | cssVarsByColor.set(normalized, { value, vars: [] }); 1121 | } 1122 | cssVarsByColor.get(normalized).vars.push(prop); 1123 | }); 1124 | 1125 | // Convert back to object with deduplicated values 1126 | const filteredCssVariables = {}; 1127 | cssVarsByColor.forEach(({ value, vars }) => { 1128 | // Only show first variable name if multiple have same value 1129 | filteredCssVariables[vars[0]] = value; 1130 | }); 1131 | 1132 | return { 1133 | semantic: semanticColors, 1134 | palette: perceptuallyDeduped, 1135 | cssVariables: filteredCssVariables, 1136 | }; 1137 | }); 1138 | } 1139 | 1140 | async function extractTypography(page) { 1141 | return await page.evaluate(() => { 1142 | const seen = new Map(); 1143 | const sources = { 1144 | googleFonts: [], 1145 | adobeFonts: false, 1146 | customFonts: [], 1147 | variableFonts: new Set(), 1148 | }; 1149 | 1150 | // ——— Font sources ——— 1151 | document 1152 | .querySelectorAll( 1153 | 'link[href*="fonts.googleapis.com"], link[href*="fonts.gstatic.com"]' 1154 | ) 1155 | .forEach((l) => { 1156 | const matches = l.href.match(/family=([^&:%]+)/g) || []; 1157 | matches.forEach((m) => { 1158 | const name = decodeURIComponent( 1159 | m.replace("family=", "").split(":")[0] 1160 | ).replace(/\+/g, " "); 1161 | if (!sources.googleFonts.includes(name)) 1162 | sources.googleFonts.push(name); 1163 | if (l.href.includes("wght") || l.href.includes("ital")) 1164 | sources.variableFonts.add(name); 1165 | }); 1166 | }); 1167 | if ( 1168 | document.querySelector( 1169 | 'link[href*="typekit.net"], script[src*="use.typekit.net"]' 1170 | ) 1171 | ) { 1172 | sources.adobeFonts = true; 1173 | } 1174 | 1175 | // Check font-display from @font-face rules 1176 | let fontDisplay = null; 1177 | try { 1178 | for (const sheet of document.styleSheets) { 1179 | try { 1180 | for (const rule of sheet.cssRules || []) { 1181 | if (rule instanceof CSSFontFaceRule) { 1182 | const display = rule.style.fontDisplay; 1183 | if (display && display !== 'auto') { 1184 | fontDisplay = display; 1185 | break; 1186 | } 1187 | } 1188 | } 1189 | } catch (e) { 1190 | // Cross-origin stylesheets 1191 | } 1192 | if (fontDisplay) break; 1193 | } 1194 | } catch (e) { 1195 | // Ignore errors 1196 | } 1197 | sources.fontDisplay = fontDisplay; 1198 | 1199 | // ——— Sample elements ——— 1200 | const els = document.querySelectorAll(` 1201 | h1,h2,h3,h4,h5,h6,p,span,a,button,[role="button"],.btn,.button, 1202 | .hero,[class*="title"],[class*="heading"],[class*="text"],nav a 1203 | `); 1204 | 1205 | els.forEach((el) => { 1206 | const s = getComputedStyle(el); 1207 | if (s.display === "none" || s.visibility === "hidden") return; 1208 | 1209 | const size = parseFloat(s.fontSize); 1210 | const weight = parseInt(s.fontWeight) || 400; 1211 | const fontFamilies = s.fontFamily.split(",").map(f => f.replace(/['"]/g, "").trim()); 1212 | const family = fontFamilies[0]; 1213 | const fallbacks = fontFamilies.slice(1).filter(f => f && f !== 'sans-serif' && f !== 'serif' && f !== 'monospace'); 1214 | const letterSpacing = s.letterSpacing; 1215 | const textTransform = s.textTransform; 1216 | const lineHeight = s.lineHeight; 1217 | 1218 | // Check for fluid typography (clamp, vw, vh) 1219 | const isFluid = s.fontSize.includes('clamp') || s.fontSize.includes('vw') || s.fontSize.includes('vh'); 1220 | 1221 | // Check for OpenType features 1222 | const fontFeatures = s.fontFeatureSettings !== 'normal' ? s.fontFeatureSettings : null; 1223 | 1224 | // Build context label 1225 | let context = "heading-1"; 1226 | const className = typeof el.className === 'string' ? el.className : (el.className.baseVal || ''); 1227 | if ( 1228 | el.tagName === "BUTTON" || 1229 | el.getAttribute("role") === "button" || 1230 | className.includes("btn") 1231 | ) { 1232 | context = "button"; 1233 | } else if (el.tagName === "A" && el.href) { 1234 | context = "link"; 1235 | } else if (size <= 14) { 1236 | context = "caption"; 1237 | } else if (el.tagName.match(/^H[1-6]$/)) { 1238 | context = "heading-1"; 1239 | } 1240 | 1241 | const key = `${family}|${size}|${weight}|${context}|${letterSpacing}|${textTransform}`; 1242 | if (seen.has(key)) return; 1243 | 1244 | // Parse line-height to unitless if possible 1245 | let lineHeightValue = null; 1246 | if (lineHeight !== 'normal') { 1247 | const lhNum = parseFloat(lineHeight); 1248 | // If line-height is in px, convert to unitless ratio 1249 | if (lineHeight.includes('px')) { 1250 | lineHeightValue = (lhNum / size).toFixed(2); 1251 | } else { 1252 | // Already unitless or in other unit 1253 | lineHeightValue = lhNum.toFixed(2); 1254 | } 1255 | } 1256 | 1257 | seen.set(key, { 1258 | context, 1259 | family, 1260 | fallbacks: fallbacks.length > 0 ? fallbacks.join(', ') : null, 1261 | size: `${size}px (${(size / 16).toFixed(2)}rem)`, 1262 | weight: weight, 1263 | lineHeight: lineHeightValue, 1264 | spacing: letterSpacing !== "normal" ? letterSpacing : null, 1265 | transform: textTransform !== "none" ? textTransform : null, 1266 | isFluid: isFluid || undefined, 1267 | fontFeatures: fontFeatures || undefined, 1268 | }); 1269 | }); 1270 | 1271 | // Sort exactly like your original output (largest → smallest) 1272 | const result = Array.from(seen.values()).sort((a, b) => { 1273 | const aSize = parseFloat(a.size); 1274 | const bSize = parseFloat(b.size); 1275 | return bSize - aSize; 1276 | }); 1277 | 1278 | return { 1279 | styles: result, 1280 | sources: { 1281 | googleFonts: sources.googleFonts, 1282 | adobeFonts: sources.adobeFonts, 1283 | variableFonts: [...sources.variableFonts].length > 0, 1284 | }, 1285 | }; 1286 | }); 1287 | } 1288 | 1289 | /** 1290 | * Extract spacing scale and detect grid system 1291 | */ 1292 | async function extractSpacing(page) { 1293 | return await page.evaluate(() => { 1294 | const spacings = new Map(); 1295 | 1296 | document.querySelectorAll("*").forEach((el) => { 1297 | const computed = getComputedStyle(el); 1298 | ["marginTop", "marginBottom", "paddingTop", "paddingBottom"].forEach( 1299 | (prop) => { 1300 | const value = parseFloat(computed[prop]); 1301 | if (value > 0) { 1302 | spacings.set(value, (spacings.get(value) || 0) + 1); 1303 | } 1304 | } 1305 | ); 1306 | }); 1307 | 1308 | const values = Array.from(spacings.entries()) 1309 | .sort((a, b) => b[1] - a[1]) // Sort by count first to get most common 1310 | .slice(0, 20) 1311 | .map(([px, count]) => ({ 1312 | px: px + "px", 1313 | rem: (px / 16).toFixed(2) + "rem", 1314 | count, 1315 | numericValue: px, 1316 | })) 1317 | .sort((a, b) => a.numericValue - b.numericValue); // Then sort by numeric value 1318 | 1319 | // Detect grid system 1320 | const is4px = values.some((v) => parseFloat(v.px) % 4 === 0); 1321 | const is8px = values.some((v) => parseFloat(v.px) % 8 === 0); 1322 | const scaleType = is8px ? "8px" : is4px ? "4px" : "custom"; 1323 | 1324 | return { scaleType, commonValues: values }; 1325 | }); 1326 | } 1327 | 1328 | /** 1329 | * Extract border radius patterns 1330 | */ 1331 | async function extractBorderRadius(page) { 1332 | return await page.evaluate(() => { 1333 | const radii = new Map(); 1334 | 1335 | document.querySelectorAll("*").forEach((el) => { 1336 | const radius = getComputedStyle(el).borderRadius; 1337 | if (radius && radius !== "0px") { 1338 | if (!radii.has(radius)) { 1339 | radii.set(radius, { count: 0, elements: new Set() }); 1340 | } 1341 | const data = radii.get(radius); 1342 | data.count++; 1343 | 1344 | // Capture element context 1345 | const tag = el.tagName.toLowerCase(); 1346 | const role = el.getAttribute('role') || el.getAttribute('aria-label'); 1347 | const classes = Array.from(el.classList); 1348 | 1349 | let context = tag; 1350 | if (role) context = role; 1351 | else if (classes.some(c => c.includes('button') || c.includes('btn'))) context = 'button'; 1352 | else if (classes.some(c => c.includes('card'))) context = 'card'; 1353 | else if (classes.some(c => c.includes('input') || c.includes('field'))) context = 'input'; 1354 | else if (classes.some(c => c.includes('badge') || c.includes('tag') || c.includes('chip'))) context = 'badge'; 1355 | else if (classes.some(c => c.includes('modal') || c.includes('dialog'))) context = 'modal'; 1356 | else if (classes.some(c => c.includes('image') || c.includes('img') || c.includes('avatar'))) context = 'image'; 1357 | 1358 | data.elements.add(context); 1359 | } 1360 | }); 1361 | 1362 | const values = Array.from(radii.entries()) 1363 | .map(([value, data]) => ({ 1364 | value, 1365 | count: data.count, 1366 | elements: Array.from(data.elements).slice(0, 5), // Limit to 5 element types 1367 | confidence: data.count > 10 ? "high" : data.count > 3 ? "medium" : "low", 1368 | numericValue: parseFloat(value) || 0, // Extract numeric value for sorting 1369 | })) 1370 | .sort((a, b) => { 1371 | // Sort by numeric value, with percentage last 1372 | if (a.value.includes("%") && !b.value.includes("%")) return 1; 1373 | if (!a.value.includes("%") && b.value.includes("%")) return -1; 1374 | return a.numericValue - b.numericValue; 1375 | }); 1376 | 1377 | return { values }; 1378 | }); 1379 | } 1380 | 1381 | /** 1382 | * Extract border patterns as complete combinations (width + style + color) 1383 | */ 1384 | async function extractBorders(page) { 1385 | return await page.evaluate(() => { 1386 | const combinations = new Map(); 1387 | 1388 | document.querySelectorAll("*").forEach((el) => { 1389 | const computed = getComputedStyle(el); 1390 | 1391 | const borderWidth = computed.borderWidth; 1392 | const borderStyle = computed.borderStyle; 1393 | const borderColor = computed.borderColor; 1394 | 1395 | // Only process visible borders 1396 | if ( 1397 | borderWidth && 1398 | borderWidth !== "0px" && 1399 | borderStyle && 1400 | borderStyle !== "none" && 1401 | borderColor && 1402 | borderColor !== "rgba(0, 0, 0, 0)" && 1403 | borderColor !== "transparent" 1404 | ) { 1405 | // Normalize color to extract individual colors from multi-value strings 1406 | const colorRegex = /(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\))/gi; 1407 | const individualColors = borderColor.match(colorRegex) || [borderColor]; 1408 | 1409 | // Use the first color or the most common one 1410 | const normalizedColor = individualColors[0]; 1411 | 1412 | if (normalizedColor && 1413 | normalizedColor !== "rgba(0, 0, 0, 0)" && 1414 | normalizedColor !== "rgba(0,0,0,0)" && 1415 | normalizedColor !== "transparent") { 1416 | 1417 | const key = `${borderWidth}|${borderStyle}|${normalizedColor}`; 1418 | 1419 | if (!combinations.has(key)) { 1420 | combinations.set(key, { 1421 | width: borderWidth, 1422 | style: borderStyle, 1423 | color: normalizedColor, 1424 | count: 0, 1425 | elements: new Set() 1426 | }); 1427 | } 1428 | 1429 | const combo = combinations.get(key); 1430 | combo.count++; 1431 | 1432 | // Capture element context 1433 | const tag = el.tagName.toLowerCase(); 1434 | const role = el.getAttribute('role'); 1435 | const classes = Array.from(el.classList); 1436 | 1437 | let context = tag; 1438 | if (role) context = role; 1439 | else if (classes.some(c => c.includes('button') || c.includes('btn'))) context = 'button'; 1440 | else if (classes.some(c => c.includes('card'))) context = 'card'; 1441 | else if (classes.some(c => c.includes('input') || c.includes('field'))) context = 'input'; 1442 | else if (classes.some(c => c.includes('modal') || c.includes('dialog'))) context = 'modal'; 1443 | 1444 | combo.elements.add(context); 1445 | } 1446 | } 1447 | }); 1448 | 1449 | const processed = Array.from(combinations.values()) 1450 | .map(combo => ({ 1451 | width: combo.width, 1452 | style: combo.style, 1453 | color: combo.color, 1454 | count: combo.count, 1455 | elements: Array.from(combo.elements).slice(0, 5), 1456 | confidence: combo.count > 10 ? "high" : combo.count > 3 ? "medium" : "low", 1457 | })) 1458 | .sort((a, b) => b.count - a.count); 1459 | 1460 | return { combinations: processed }; 1461 | }); 1462 | } 1463 | 1464 | /** 1465 | * Extract box shadow patterns for elevation systems 1466 | */ 1467 | async function extractShadows(page) { 1468 | return await page.evaluate(() => { 1469 | const shadows = new Map(); 1470 | 1471 | document.querySelectorAll("*").forEach((el) => { 1472 | const shadow = getComputedStyle(el).boxShadow; 1473 | if (shadow && shadow !== "none") { 1474 | shadows.set(shadow, (shadows.get(shadow) || 0) + 1); 1475 | } 1476 | }); 1477 | 1478 | return Array.from(shadows.entries()) 1479 | .map(([shadow, count]) => ({ 1480 | shadow, 1481 | count, 1482 | confidence: count > 5 ? "high" : count > 2 ? "medium" : "low", 1483 | })) 1484 | .sort((a, b) => b.count - a.count); 1485 | }); 1486 | } 1487 | 1488 | /** 1489 | * Extract button component styles and variants 1490 | */ 1491 | async function extractButtonStyles(page) { 1492 | return await page.evaluate(() => { 1493 | const buttons = Array.from( 1494 | document.querySelectorAll(` 1495 | button, 1496 | a[type="button"], 1497 | [role="button"], 1498 | [role="tab"], 1499 | [role="menuitem"], 1500 | [role="switch"], 1501 | [aria-pressed], 1502 | [aria-expanded], 1503 | .btn, 1504 | [class*="btn"], 1505 | [class*="button"], 1506 | [class*="cta"], 1507 | [data-cta] 1508 | `) 1509 | ); 1510 | 1511 | const extractState = (btn, stateName = 'default') => { 1512 | const computed = getComputedStyle(btn); 1513 | return { 1514 | backgroundColor: computed.backgroundColor, 1515 | color: computed.color, 1516 | padding: computed.padding, 1517 | borderRadius: computed.borderRadius, 1518 | border: computed.border, 1519 | boxShadow: computed.boxShadow, 1520 | outline: computed.outline, 1521 | transform: computed.transform, 1522 | opacity: computed.opacity, 1523 | }; 1524 | }; 1525 | 1526 | const buttonStyles = []; 1527 | 1528 | buttons.forEach((btn) => { 1529 | const computed = getComputedStyle(btn); 1530 | 1531 | // Skip if not visible 1532 | const rect = btn.getBoundingClientRect(); 1533 | if (rect.width === 0 || rect.height === 0 || computed.display === 'none' || computed.visibility === 'hidden') { 1534 | return; 1535 | } 1536 | 1537 | // Check if button has any styling (background OR border) 1538 | const bg = computed.backgroundColor; 1539 | const border = computed.border; 1540 | const borderWidth = computed.borderWidth; 1541 | const hasBorder = borderWidth && parseFloat(borderWidth) > 0 && border !== 'none'; 1542 | const hasBackground = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'; 1543 | 1544 | // Skip only if BOTH background and border are missing/transparent 1545 | if (!hasBackground && !hasBorder) { 1546 | return; 1547 | } 1548 | 1549 | // Determine confidence based on semantic HTML and ARIA 1550 | const role = btn.getAttribute('role'); 1551 | const isNativeButton = btn.tagName === "BUTTON"; 1552 | const isButtonRole = ['button', 'tab', 'menuitem', 'switch'].includes(role); 1553 | const hasAriaPressed = btn.hasAttribute('aria-pressed'); 1554 | const hasAriaExpanded = btn.hasAttribute('aria-expanded'); 1555 | const isHighConfidence = isNativeButton || isButtonRole || hasAriaPressed || hasAriaExpanded; 1556 | 1557 | // Handle className for both HTML and SVG elements 1558 | const className = typeof btn.className === 'string' 1559 | ? btn.className 1560 | : btn.className.baseVal || ''; 1561 | 1562 | // Extract default state 1563 | const defaultState = extractState(btn, 'default'); 1564 | 1565 | // Try to extract pseudo-class states from stylesheets 1566 | const states = { 1567 | default: defaultState, 1568 | hover: null, 1569 | active: null, 1570 | focus: null, 1571 | }; 1572 | 1573 | // Attempt to read CSS rules for hover, active, focus states 1574 | try { 1575 | const sheets = Array.from(document.styleSheets); 1576 | for (const sheet of sheets) { 1577 | try { 1578 | const rules = Array.from(sheet.cssRules || []); 1579 | for (const rule of rules) { 1580 | if (rule.selectorText) { 1581 | // Check if this rule could apply to our button 1582 | const btnClasses = className.split(' ').filter(c => c); 1583 | const matchesButton = btnClasses.some(cls => 1584 | rule.selectorText.includes(`.${cls}`) 1585 | ); 1586 | 1587 | if (matchesButton || rule.selectorText.includes(btn.tagName.toLowerCase())) { 1588 | if (rule.selectorText.includes(':hover')) { 1589 | if (!states.hover) states.hover = {}; 1590 | if (rule.style.backgroundColor) states.hover.backgroundColor = rule.style.backgroundColor; 1591 | if (rule.style.color) states.hover.color = rule.style.color; 1592 | if (rule.style.boxShadow) states.hover.boxShadow = rule.style.boxShadow; 1593 | if (rule.style.outline) states.hover.outline = rule.style.outline; 1594 | if (rule.style.border) states.hover.border = rule.style.border; 1595 | if (rule.style.transform) states.hover.transform = rule.style.transform; 1596 | if (rule.style.opacity) states.hover.opacity = rule.style.opacity; 1597 | } 1598 | if (rule.selectorText.includes(':active')) { 1599 | if (!states.active) states.active = {}; 1600 | if (rule.style.backgroundColor) states.active.backgroundColor = rule.style.backgroundColor; 1601 | if (rule.style.color) states.active.color = rule.style.color; 1602 | if (rule.style.boxShadow) states.active.boxShadow = rule.style.boxShadow; 1603 | if (rule.style.outline) states.active.outline = rule.style.outline; 1604 | if (rule.style.border) states.active.border = rule.style.border; 1605 | if (rule.style.transform) states.active.transform = rule.style.transform; 1606 | if (rule.style.opacity) states.active.opacity = rule.style.opacity; 1607 | } 1608 | if (rule.selectorText.includes(':focus')) { 1609 | if (!states.focus) states.focus = {}; 1610 | if (rule.style.backgroundColor) states.focus.backgroundColor = rule.style.backgroundColor; 1611 | if (rule.style.color) states.focus.color = rule.style.color; 1612 | if (rule.style.boxShadow) states.focus.boxShadow = rule.style.boxShadow; 1613 | if (rule.style.outline) states.focus.outline = rule.style.outline; 1614 | if (rule.style.border) states.focus.border = rule.style.border; 1615 | if (rule.style.transform) states.focus.transform = rule.style.transform; 1616 | if (rule.style.opacity) states.focus.opacity = rule.style.opacity; 1617 | } 1618 | } 1619 | } 1620 | } 1621 | } catch (e) { 1622 | // CORS or other stylesheet access error, skip 1623 | } 1624 | } 1625 | } catch (e) { 1626 | // Stylesheet parsing failed 1627 | } 1628 | 1629 | buttonStyles.push({ 1630 | states, 1631 | fontWeight: computed.fontWeight, 1632 | fontSize: computed.fontSize, 1633 | classes: className.substring(0, 50), 1634 | confidence: isHighConfidence ? "high" : "medium", 1635 | }); 1636 | }); 1637 | 1638 | // Deduplicate by default state background color 1639 | const uniqueButtons = []; 1640 | const seen = new Set(); 1641 | 1642 | for (const btn of buttonStyles) { 1643 | const key = btn.states.default.backgroundColor; 1644 | if (!seen.has(key)) { 1645 | seen.add(key); 1646 | uniqueButtons.push(btn); 1647 | } 1648 | } 1649 | 1650 | return uniqueButtons.slice(0, 15); 1651 | }); 1652 | } 1653 | 1654 | /** 1655 | * Extract input field styles with states 1656 | */ 1657 | async function extractInputStyles(page) { 1658 | return await page.evaluate(() => { 1659 | const inputs = Array.from( 1660 | document.querySelectorAll(` 1661 | input[type="text"], 1662 | input[type="email"], 1663 | input[type="password"], 1664 | input[type="search"], 1665 | input[type="tel"], 1666 | input[type="url"], 1667 | input[type="number"], 1668 | input[type="checkbox"], 1669 | input[type="radio"], 1670 | textarea, 1671 | select, 1672 | [role="textbox"], 1673 | [role="searchbox"], 1674 | [role="combobox"], 1675 | [contenteditable="true"] 1676 | `) 1677 | ); 1678 | 1679 | const inputGroups = { 1680 | text: [], 1681 | checkbox: [], 1682 | radio: [], 1683 | select: [], 1684 | }; 1685 | 1686 | inputs.forEach((input) => { 1687 | const computed = getComputedStyle(input); 1688 | 1689 | // Skip if not visible 1690 | const rect = input.getBoundingClientRect(); 1691 | if (rect.width === 0 || rect.height === 0 || computed.display === 'none' || computed.visibility === 'hidden') { 1692 | return; 1693 | } 1694 | 1695 | let inputType = 'text'; 1696 | if (input.tagName === 'TEXTAREA') { 1697 | inputType = 'text'; 1698 | } else if (input.tagName === 'SELECT') { 1699 | inputType = 'select'; 1700 | } else if (input.type === 'checkbox') { 1701 | inputType = 'checkbox'; 1702 | } else if (input.type === 'radio') { 1703 | inputType = 'radio'; 1704 | } else if (['text', 'email', 'password', 'search', 'tel', 'url', 'number'].includes(input.type)) { 1705 | inputType = 'text'; 1706 | } 1707 | 1708 | const specificType = input.type || input.tagName.toLowerCase(); 1709 | 1710 | const defaultState = { 1711 | backgroundColor: computed.backgroundColor, 1712 | color: computed.color, 1713 | border: computed.border, 1714 | borderRadius: computed.borderRadius, 1715 | padding: computed.padding, 1716 | boxShadow: computed.boxShadow, 1717 | outline: computed.outline, 1718 | }; 1719 | 1720 | // Try to extract focus state from CSS rules 1721 | let focusState = null; 1722 | try { 1723 | const sheets = Array.from(document.styleSheets); 1724 | const className = typeof input.className === 'string' ? input.className : input.className.baseVal || ''; 1725 | const classes = className.split(' ').filter(c => c); 1726 | 1727 | for (const sheet of sheets) { 1728 | try { 1729 | const rules = Array.from(sheet.cssRules || []); 1730 | for (const rule of rules) { 1731 | if (rule.selectorText) { 1732 | const matchesInput = classes.some(cls => rule.selectorText.includes(`.${cls}`)) || 1733 | rule.selectorText.includes(input.tagName.toLowerCase()) || 1734 | (input.type && rule.selectorText.includes(`[type="${input.type}"]`)); 1735 | 1736 | if (matchesInput && rule.selectorText.includes(':focus')) { 1737 | if (!focusState) focusState = {}; 1738 | if (rule.style.backgroundColor) focusState.backgroundColor = rule.style.backgroundColor; 1739 | if (rule.style.color) focusState.color = rule.style.color; 1740 | if (rule.style.border) focusState.border = rule.style.border; 1741 | if (rule.style.borderColor) focusState.borderColor = rule.style.borderColor; 1742 | if (rule.style.boxShadow) focusState.boxShadow = rule.style.boxShadow; 1743 | if (rule.style.outline) focusState.outline = rule.style.outline; 1744 | } 1745 | } 1746 | } 1747 | } catch (e) { 1748 | // CORS error 1749 | } 1750 | } 1751 | } catch (e) { 1752 | // Stylesheet parsing failed 1753 | } 1754 | 1755 | inputGroups[inputType].push({ 1756 | specificType, 1757 | states: { 1758 | default: defaultState, 1759 | focus: focusState, 1760 | }, 1761 | }); 1762 | }); 1763 | 1764 | // Deduplicate each group by key properties 1765 | const deduplicateGroup = (group) => { 1766 | const seen = new Map(); 1767 | for (const item of group) { 1768 | const key = `${item.states.default.border}|${item.states.default.borderRadius}|${item.states.default.backgroundColor}`; 1769 | if (!seen.has(key)) { 1770 | seen.set(key, item); 1771 | } 1772 | } 1773 | return Array.from(seen.values()); 1774 | }; 1775 | 1776 | return { 1777 | text: deduplicateGroup(inputGroups.text).slice(0, 5), 1778 | checkbox: deduplicateGroup(inputGroups.checkbox).slice(0, 3), 1779 | radio: deduplicateGroup(inputGroups.radio).slice(0, 3), 1780 | select: deduplicateGroup(inputGroups.select).slice(0, 3), 1781 | }; 1782 | }); 1783 | } 1784 | 1785 | /** 1786 | * Extract link styles including hover states 1787 | */ 1788 | async function extractLinkStyles(page) { 1789 | return await page.evaluate(() => { 1790 | const links = Array.from( 1791 | document.querySelectorAll(` 1792 | a, 1793 | [role="link"], 1794 | [aria-current] 1795 | `) 1796 | ); 1797 | 1798 | const uniqueStyles = new Map(); 1799 | 1800 | links.forEach((link) => { 1801 | const computed = getComputedStyle(link); 1802 | 1803 | // Skip if not visible 1804 | const rect = link.getBoundingClientRect(); 1805 | if (rect.width === 0 || rect.height === 0 || computed.display === 'none' || computed.visibility === 'hidden') { 1806 | return; 1807 | } 1808 | 1809 | // Normalize color to hex for deduplication 1810 | const normalizeColor = (color) => { 1811 | try { 1812 | const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); 1813 | if (rgbaMatch) { 1814 | const r = parseInt(rgbaMatch[1]); 1815 | const g = parseInt(rgbaMatch[2]); 1816 | const b = parseInt(rgbaMatch[3]); 1817 | return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; 1818 | } 1819 | return color.toLowerCase(); 1820 | } catch { 1821 | return color; 1822 | } 1823 | }; 1824 | 1825 | const key = normalizeColor(computed.color); 1826 | 1827 | if (!uniqueStyles.has(key)) { 1828 | // Try to extract hover state from CSS rules 1829 | let hoverState = null; 1830 | try { 1831 | const sheets = Array.from(document.styleSheets); 1832 | const className = typeof link.className === 'string' ? link.className : link.className.baseVal || ''; 1833 | const classes = className.split(' ').filter(c => c); 1834 | 1835 | for (const sheet of sheets) { 1836 | try { 1837 | const rules = Array.from(sheet.cssRules || []); 1838 | for (const rule of rules) { 1839 | if (rule.selectorText) { 1840 | const matchesLink = classes.some(cls => rule.selectorText.includes(`.${cls}`)) || 1841 | rule.selectorText.includes('a:hover'); 1842 | 1843 | if (matchesLink && rule.selectorText.includes(':hover')) { 1844 | if (!hoverState) hoverState = {}; 1845 | if (rule.style.color) hoverState.color = rule.style.color; 1846 | if (rule.style.textDecoration) hoverState.textDecoration = rule.style.textDecoration; 1847 | } 1848 | } 1849 | } 1850 | } catch (e) { 1851 | // CORS error 1852 | } 1853 | } 1854 | } catch (e) { 1855 | // Stylesheet parsing failed 1856 | } 1857 | 1858 | uniqueStyles.set(key, { 1859 | color: computed.color, 1860 | textDecoration: computed.textDecoration, 1861 | fontWeight: computed.fontWeight, 1862 | states: { 1863 | default: { 1864 | color: computed.color, 1865 | textDecoration: computed.textDecoration, 1866 | }, 1867 | hover: hoverState, 1868 | }, 1869 | }); 1870 | } else { 1871 | // If we already have this color, update decoration if this one is more specific 1872 | const existing = uniqueStyles.get(key); 1873 | if (!existing.states.default.textDecoration || existing.states.default.textDecoration === 'none') { 1874 | if (computed.textDecoration && computed.textDecoration !== 'none') { 1875 | existing.states.default.textDecoration = computed.textDecoration; 1876 | } 1877 | } 1878 | } 1879 | }); 1880 | 1881 | return Array.from(uniqueStyles.values()).slice(0, 8); 1882 | }); 1883 | } 1884 | 1885 | /** 1886 | * Detect responsive breakpoints from CSS 1887 | */ 1888 | async function extractBreakpoints(page) { 1889 | return await page.evaluate(() => { 1890 | const breakpoints = new Set(); 1891 | 1892 | for (const sheet of document.styleSheets) { 1893 | try { 1894 | for (const rule of sheet.cssRules || []) { 1895 | if (rule.media) { 1896 | const match = rule.media.mediaText.match(/(\d+)px/g); 1897 | if (match) match.forEach((m) => breakpoints.add(parseInt(m))); 1898 | } 1899 | } 1900 | } catch (e) { 1901 | // Cross-origin stylesheets may throw errors 1902 | } 1903 | } 1904 | 1905 | return Array.from(breakpoints) 1906 | .sort((a, b) => a - b) 1907 | .map((px) => ({ px: px + "px" })); 1908 | }); 1909 | } 1910 | 1911 | /** 1912 | * Detect icon systems in use 1913 | */ 1914 | async function detectIconSystem(page) { 1915 | return await page.evaluate(() => { 1916 | const systems = []; 1917 | 1918 | if (document.querySelector('[class*="fa-"]')) { 1919 | systems.push({ name: "Font Awesome", type: "icon-font" }); 1920 | } 1921 | if (document.querySelector('[class*="material-icons"]')) { 1922 | systems.push({ name: "Material Icons", type: "icon-font" }); 1923 | } 1924 | if (document.querySelector('svg[class*="icon"]')) { 1925 | systems.push({ name: "SVG Icons", type: "svg" }); 1926 | } 1927 | 1928 | return systems; 1929 | }); 1930 | } 1931 | 1932 | /** 1933 | * Detect CSS frameworks and libraries 1934 | */ 1935 | async function detectFrameworks(page) { 1936 | return await page.evaluate(() => { 1937 | const frameworks = []; 1938 | const html = document.documentElement.outerHTML; 1939 | const body = document.body; 1940 | 1941 | // Helper: Count elements with pattern 1942 | function countMatches(selector) { 1943 | try { 1944 | return document.querySelectorAll(selector).length; 1945 | } catch { 1946 | return 0; 1947 | } 1948 | } 1949 | 1950 | // Helper: Check if stylesheet/script exists 1951 | function hasResource(pattern) { 1952 | const links = Array.from(document.querySelectorAll('link[href], script[src]')); 1953 | return links.some(el => pattern.test(el.href || el.src)); 1954 | } 1955 | 1956 | // 1. Tailwind CSS - Very specific detection 1957 | const tailwindEvidence = []; 1958 | 1959 | // Arbitrary values are almost 100% unique to Tailwind 1960 | if (/\b\w+-\[[^\]]+\]/.test(html)) { 1961 | tailwindEvidence.push('arbitrary values (e.g., top-[117px])'); 1962 | } 1963 | 1964 | // Stacked modifiers (md:hover:, dark:, etc.) 1965 | if (/(sm|md|lg|xl|2xl|dark|hover|focus|group-hover|peer-):[a-z]/.test(html)) { 1966 | tailwindEvidence.push('responsive/state modifiers'); 1967 | } 1968 | 1969 | // CDN or build file 1970 | if (hasResource(/tailwindcss|tailwind\.css|cdn\.tailwindcss/)) { 1971 | tailwindEvidence.push('stylesheet'); 1972 | } 1973 | 1974 | if (tailwindEvidence.length >= 2) { 1975 | frameworks.push({ 1976 | name: 'Tailwind CSS', 1977 | confidence: 'high', 1978 | evidence: tailwindEvidence.join(', ') 1979 | }); 1980 | } 1981 | 1982 | // 2. Bootstrap - Check for specific patterns + CDN 1983 | const bootstrapEvidence = []; 1984 | 1985 | // Bootstrap has very specific class combinations 1986 | const hasContainer = countMatches('.container, .container-fluid') > 0; 1987 | const hasRow = countMatches('.row') > 0; 1988 | const hasCol = countMatches('[class*="col-"]') > 0; 1989 | 1990 | if (hasContainer && hasRow && hasCol) { 1991 | bootstrapEvidence.push('grid system (container + row + col)'); 1992 | } 1993 | 1994 | // Bootstrap buttons are very predictable 1995 | if (/\bbtn-primary\b|\bbtn-secondary\b|\bbtn-success\b/.test(html)) { 1996 | bootstrapEvidence.push('button variants'); 1997 | } 1998 | 1999 | // CDN or build file 2000 | if (hasResource(/bootstrap\.min\.css|bootstrap\.css|getbootstrap\.com/)) { 2001 | bootstrapEvidence.push('stylesheet'); 2002 | } 2003 | 2004 | if (bootstrapEvidence.length >= 2) { 2005 | frameworks.push({ 2006 | name: 'Bootstrap', 2007 | confidence: 'high', 2008 | evidence: bootstrapEvidence.join(', ') 2009 | }); 2010 | } 2011 | 2012 | // 3. Material UI (MUI) - Very distinctive 2013 | const muiCount = countMatches('[class*="MuiBox-"], [class*="MuiButton-"], [class*="Mui"]'); 2014 | if (muiCount > 3) { 2015 | frameworks.push({ 2016 | name: 'Material UI (MUI)', 2017 | confidence: 'high', 2018 | evidence: `${muiCount} MUI components` 2019 | }); 2020 | } 2021 | 2022 | // 4. Chakra UI - Check for chakra- prefix 2023 | const chakraCount = countMatches('[class*="chakra-"]'); 2024 | if (chakraCount > 3) { 2025 | frameworks.push({ 2026 | name: 'Chakra UI', 2027 | confidence: 'high', 2028 | evidence: `${chakraCount} Chakra components` 2029 | }); 2030 | } 2031 | 2032 | // 5. Ant Design - ant- prefix at word boundary (not "merchant", "assistant") 2033 | const antCount = countMatches('[class^="ant-"], [class*=" ant-"]'); 2034 | if (antCount > 3) { 2035 | frameworks.push({ 2036 | name: 'Ant Design', 2037 | confidence: 'high', 2038 | evidence: `${antCount} Ant components` 2039 | }); 2040 | } 2041 | 2042 | // 6. Vuetify - v- prefix + theme attributes 2043 | const vuetifyCount = countMatches('[class*="v-btn"], [class*="v-card"], [class*="v-"]'); 2044 | const hasVuetifyTheme = body.classList.contains('theme--light') || body.classList.contains('theme--dark'); 2045 | if (vuetifyCount > 5 || hasVuetifyTheme) { 2046 | frameworks.push({ 2047 | name: 'Vuetify', 2048 | confidence: 'high', 2049 | evidence: `${vuetifyCount} v- components` 2050 | }); 2051 | } 2052 | 2053 | // 7. Shopify Polaris - Very long Polaris- prefixed classes 2054 | const polarisCount = countMatches('[class*="Polaris-"]'); 2055 | if (polarisCount > 2) { 2056 | frameworks.push({ 2057 | name: 'Shopify Polaris', 2058 | confidence: 'high', 2059 | evidence: `${polarisCount} Polaris components` 2060 | }); 2061 | } 2062 | 2063 | // 8. Radix UI - data-radix- attributes (headless) 2064 | const radixCount = document.querySelectorAll('[data-radix-], [data-state]').length; 2065 | if (radixCount > 5) { 2066 | frameworks.push({ 2067 | name: 'Radix UI', 2068 | confidence: 'high', 2069 | evidence: `${radixCount} Radix primitives` 2070 | }); 2071 | } 2072 | 2073 | // 9. DaisyUI - Requires Tailwind + specific DaisyUI-only component classes 2074 | if (tailwindEvidence.length >= 2) { 2075 | // Check for DaisyUI-specific classes that aren't in standard Tailwind 2076 | const daisySpecific = countMatches('.btn-primary.btn, .badge, .drawer, .swap, .mockup-code'); 2077 | const hasDaisyTheme = body.hasAttribute('data-theme'); 2078 | if (daisySpecific > 3 || hasDaisyTheme) { 2079 | frameworks.push({ 2080 | name: 'DaisyUI', 2081 | confidence: 'high', 2082 | evidence: `Tailwind + ${daisySpecific} DaisyUI components` 2083 | }); 2084 | } 2085 | } 2086 | 2087 | return frameworks; 2088 | }); 2089 | } 2090 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dembrandt", 3 | "version": "0.2.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "dembrandt", 9 | "version": "0.2.0", 10 | "hasInstallScript": true, 11 | "license": "MIT", 12 | "dependencies": { 13 | "chalk": "^5.3.0", 14 | "commander": "^11.1.0", 15 | "ora": "^7.0.1", 16 | "playwright": "^1.40.0" 17 | }, 18 | "bin": { 19 | "dembrandt": "index.js" 20 | }, 21 | "engines": { 22 | "node": ">=18.0.0" 23 | } 24 | }, 25 | "node_modules/ansi-regex": { 26 | "version": "6.2.2", 27 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", 28 | "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", 29 | "license": "MIT", 30 | "engines": { 31 | "node": ">=12" 32 | }, 33 | "funding": { 34 | "url": "https://github.com/chalk/ansi-regex?sponsor=1" 35 | } 36 | }, 37 | "node_modules/base64-js": { 38 | "version": "1.5.1", 39 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 40 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 41 | "funding": [ 42 | { 43 | "type": "github", 44 | "url": "https://github.com/sponsors/feross" 45 | }, 46 | { 47 | "type": "patreon", 48 | "url": "https://www.patreon.com/feross" 49 | }, 50 | { 51 | "type": "consulting", 52 | "url": "https://feross.org/support" 53 | } 54 | ], 55 | "license": "MIT" 56 | }, 57 | "node_modules/bl": { 58 | "version": "5.1.0", 59 | "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", 60 | "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", 61 | "license": "MIT", 62 | "dependencies": { 63 | "buffer": "^6.0.3", 64 | "inherits": "^2.0.4", 65 | "readable-stream": "^3.4.0" 66 | } 67 | }, 68 | "node_modules/buffer": { 69 | "version": "6.0.3", 70 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", 71 | "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", 72 | "funding": [ 73 | { 74 | "type": "github", 75 | "url": "https://github.com/sponsors/feross" 76 | }, 77 | { 78 | "type": "patreon", 79 | "url": "https://www.patreon.com/feross" 80 | }, 81 | { 82 | "type": "consulting", 83 | "url": "https://feross.org/support" 84 | } 85 | ], 86 | "license": "MIT", 87 | "dependencies": { 88 | "base64-js": "^1.3.1", 89 | "ieee754": "^1.2.1" 90 | } 91 | }, 92 | "node_modules/chalk": { 93 | "version": "5.6.2", 94 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", 95 | "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", 96 | "license": "MIT", 97 | "engines": { 98 | "node": "^12.17.0 || ^14.13 || >=16.0.0" 99 | }, 100 | "funding": { 101 | "url": "https://github.com/chalk/chalk?sponsor=1" 102 | } 103 | }, 104 | "node_modules/cli-cursor": { 105 | "version": "4.0.0", 106 | "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", 107 | "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", 108 | "license": "MIT", 109 | "dependencies": { 110 | "restore-cursor": "^4.0.0" 111 | }, 112 | "engines": { 113 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 114 | }, 115 | "funding": { 116 | "url": "https://github.com/sponsors/sindresorhus" 117 | } 118 | }, 119 | "node_modules/commander": { 120 | "version": "11.1.0", 121 | "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", 122 | "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", 123 | "license": "MIT", 124 | "engines": { 125 | "node": ">=16" 126 | } 127 | }, 128 | "node_modules/eastasianwidth": { 129 | "version": "0.2.0", 130 | "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", 131 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", 132 | "license": "MIT" 133 | }, 134 | "node_modules/fsevents": { 135 | "version": "2.3.2", 136 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 137 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 138 | "hasInstallScript": true, 139 | "license": "MIT", 140 | "optional": true, 141 | "os": [ 142 | "darwin" 143 | ], 144 | "engines": { 145 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 146 | } 147 | }, 148 | "node_modules/ieee754": { 149 | "version": "1.2.1", 150 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 151 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 152 | "funding": [ 153 | { 154 | "type": "github", 155 | "url": "https://github.com/sponsors/feross" 156 | }, 157 | { 158 | "type": "patreon", 159 | "url": "https://www.patreon.com/feross" 160 | }, 161 | { 162 | "type": "consulting", 163 | "url": "https://feross.org/support" 164 | } 165 | ], 166 | "license": "BSD-3-Clause" 167 | }, 168 | "node_modules/inherits": { 169 | "version": "2.0.4", 170 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 171 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 172 | "license": "ISC" 173 | }, 174 | "node_modules/is-interactive": { 175 | "version": "2.0.0", 176 | "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", 177 | "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", 178 | "license": "MIT", 179 | "engines": { 180 | "node": ">=12" 181 | }, 182 | "funding": { 183 | "url": "https://github.com/sponsors/sindresorhus" 184 | } 185 | }, 186 | "node_modules/is-unicode-supported": { 187 | "version": "1.3.0", 188 | "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", 189 | "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", 190 | "license": "MIT", 191 | "engines": { 192 | "node": ">=12" 193 | }, 194 | "funding": { 195 | "url": "https://github.com/sponsors/sindresorhus" 196 | } 197 | }, 198 | "node_modules/log-symbols": { 199 | "version": "5.1.0", 200 | "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", 201 | "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", 202 | "license": "MIT", 203 | "dependencies": { 204 | "chalk": "^5.0.0", 205 | "is-unicode-supported": "^1.1.0" 206 | }, 207 | "engines": { 208 | "node": ">=12" 209 | }, 210 | "funding": { 211 | "url": "https://github.com/sponsors/sindresorhus" 212 | } 213 | }, 214 | "node_modules/mimic-fn": { 215 | "version": "2.1.0", 216 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", 217 | "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", 218 | "license": "MIT", 219 | "engines": { 220 | "node": ">=6" 221 | } 222 | }, 223 | "node_modules/onetime": { 224 | "version": "5.1.2", 225 | "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", 226 | "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", 227 | "license": "MIT", 228 | "dependencies": { 229 | "mimic-fn": "^2.1.0" 230 | }, 231 | "engines": { 232 | "node": ">=6" 233 | }, 234 | "funding": { 235 | "url": "https://github.com/sponsors/sindresorhus" 236 | } 237 | }, 238 | "node_modules/ora": { 239 | "version": "7.0.1", 240 | "resolved": "https://registry.npmjs.org/ora/-/ora-7.0.1.tgz", 241 | "integrity": "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==", 242 | "license": "MIT", 243 | "dependencies": { 244 | "chalk": "^5.3.0", 245 | "cli-cursor": "^4.0.0", 246 | "cli-spinners": "^2.9.0", 247 | "is-interactive": "^2.0.0", 248 | "is-unicode-supported": "^1.3.0", 249 | "log-symbols": "^5.1.0", 250 | "stdin-discarder": "^0.1.0", 251 | "string-width": "^6.1.0", 252 | "strip-ansi": "^7.1.0" 253 | }, 254 | "engines": { 255 | "node": ">=16" 256 | }, 257 | "funding": { 258 | "url": "https://github.com/sponsors/sindresorhus" 259 | } 260 | }, 261 | "node_modules/ora/node_modules/cli-spinners": { 262 | "version": "2.9.2", 263 | "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", 264 | "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", 265 | "license": "MIT", 266 | "engines": { 267 | "node": ">=6" 268 | }, 269 | "funding": { 270 | "url": "https://github.com/sponsors/sindresorhus" 271 | } 272 | }, 273 | "node_modules/ora/node_modules/emoji-regex": { 274 | "version": "10.6.0", 275 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", 276 | "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", 277 | "license": "MIT" 278 | }, 279 | "node_modules/ora/node_modules/string-width": { 280 | "version": "6.1.0", 281 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-6.1.0.tgz", 282 | "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", 283 | "license": "MIT", 284 | "dependencies": { 285 | "eastasianwidth": "^0.2.0", 286 | "emoji-regex": "^10.2.1", 287 | "strip-ansi": "^7.0.1" 288 | }, 289 | "engines": { 290 | "node": ">=16" 291 | }, 292 | "funding": { 293 | "url": "https://github.com/sponsors/sindresorhus" 294 | } 295 | }, 296 | "node_modules/playwright": { 297 | "version": "1.56.1", 298 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", 299 | "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", 300 | "license": "Apache-2.0", 301 | "dependencies": { 302 | "playwright-core": "1.56.1" 303 | }, 304 | "bin": { 305 | "playwright": "cli.js" 306 | }, 307 | "engines": { 308 | "node": ">=18" 309 | }, 310 | "optionalDependencies": { 311 | "fsevents": "2.3.2" 312 | } 313 | }, 314 | "node_modules/playwright-core": { 315 | "version": "1.56.1", 316 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", 317 | "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", 318 | "license": "Apache-2.0", 319 | "bin": { 320 | "playwright-core": "cli.js" 321 | }, 322 | "engines": { 323 | "node": ">=18" 324 | } 325 | }, 326 | "node_modules/readable-stream": { 327 | "version": "3.6.2", 328 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 329 | "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 330 | "license": "MIT", 331 | "dependencies": { 332 | "inherits": "^2.0.3", 333 | "string_decoder": "^1.1.1", 334 | "util-deprecate": "^1.0.1" 335 | }, 336 | "engines": { 337 | "node": ">= 6" 338 | } 339 | }, 340 | "node_modules/restore-cursor": { 341 | "version": "4.0.0", 342 | "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", 343 | "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", 344 | "license": "MIT", 345 | "dependencies": { 346 | "onetime": "^5.1.0", 347 | "signal-exit": "^3.0.2" 348 | }, 349 | "engines": { 350 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 351 | }, 352 | "funding": { 353 | "url": "https://github.com/sponsors/sindresorhus" 354 | } 355 | }, 356 | "node_modules/safe-buffer": { 357 | "version": "5.2.1", 358 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 359 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 360 | "funding": [ 361 | { 362 | "type": "github", 363 | "url": "https://github.com/sponsors/feross" 364 | }, 365 | { 366 | "type": "patreon", 367 | "url": "https://www.patreon.com/feross" 368 | }, 369 | { 370 | "type": "consulting", 371 | "url": "https://feross.org/support" 372 | } 373 | ], 374 | "license": "MIT" 375 | }, 376 | "node_modules/signal-exit": { 377 | "version": "3.0.7", 378 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", 379 | "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", 380 | "license": "ISC" 381 | }, 382 | "node_modules/stdin-discarder": { 383 | "version": "0.1.0", 384 | "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", 385 | "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", 386 | "license": "MIT", 387 | "dependencies": { 388 | "bl": "^5.0.0" 389 | }, 390 | "engines": { 391 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 392 | }, 393 | "funding": { 394 | "url": "https://github.com/sponsors/sindresorhus" 395 | } 396 | }, 397 | "node_modules/string_decoder": { 398 | "version": "1.3.0", 399 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 400 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 401 | "license": "MIT", 402 | "dependencies": { 403 | "safe-buffer": "~5.2.0" 404 | } 405 | }, 406 | "node_modules/strip-ansi": { 407 | "version": "7.1.2", 408 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", 409 | "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", 410 | "license": "MIT", 411 | "dependencies": { 412 | "ansi-regex": "^6.0.1" 413 | }, 414 | "engines": { 415 | "node": ">=12" 416 | }, 417 | "funding": { 418 | "url": "https://github.com/chalk/strip-ansi?sponsor=1" 419 | } 420 | }, 421 | "node_modules/util-deprecate": { 422 | "version": "1.0.2", 423 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 424 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 425 | "license": "MIT" 426 | } 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dembrandt", 3 | "version": "0.3.0", 4 | "description": "Extract design tokens and brand assets from any website", 5 | "main": "index.js", 6 | "type": "module", 7 | "bin": { 8 | "dembrandt": "./index.js" 9 | }, 10 | "files": [ 11 | "index.js", 12 | "lib/" 13 | ], 14 | "scripts": { 15 | "start": "node index.js", 16 | "brand-challenge": "node run-no-login-challenge.mjs", 17 | "brand-challenge:report": "node run-no-login-challenge.mjs || true", 18 | "install-browser": "npx playwright install chromium", 19 | "postinstall": "npx playwright install chromium --with-deps || echo 'Playwright install failed, you may need to run: npx playwright install chromium'", 20 | "release": "./release.sh" 21 | }, 22 | "keywords": [ 23 | "design-tokens", 24 | "design-system", 25 | "branding", 26 | "web-scraping", 27 | "cli", 28 | "playwright", 29 | "extraction" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/thevangelist/dembrandt.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/thevangelist/dembrandt/issues" 37 | }, 38 | "homepage": "https://github.com/thevangelist/dembrandt#readme", 39 | "author": "thevangelist ", 40 | "license": "MIT", 41 | "dependencies": { 42 | "chalk": "^5.3.0", 43 | "commander": "^11.1.0", 44 | "ora": "^7.0.1", 45 | "playwright": "^1.40.0" 46 | }, 47 | "engines": { 48 | "node": ">=18.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /run-no-login-challenge.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // run-global-challenge-suite.mjs – 100% working version (2025) 3 | 4 | import { execSync } from "child_process"; 5 | 6 | const sites = [ 7 | "tesla.com/model3/design#overview", // SPA, WebGL/3D, bot protection 8 | "dribbble.com", // interactive previews, heavy JS 9 | "soundcloud.com/discover", // SPA, media streaming, async loading 10 | "airtable.com/templates", // SaaS grids, dynamic content 11 | "producthunt.com", // SPA, async product listings 12 | "behance.net", // portfolios, AJAX-loaded content 13 | ]; 14 | 15 | let passed = 0; 16 | let failed = 0; 17 | const startTotal = Date.now(); 18 | 19 | console.log("═══════════════════════════════════════════════════════════"); 20 | console.log(" Dembrandt Global Hardest Sites Challenge 2025"); 21 | console.log("═══════════════════════════════════════════════════════════\n"); 22 | 23 | for (const [i, site] of sites.entries()) { 24 | const start = Date.now(); 25 | console.log(`\n[${i + 1}/${sites.length}] Testing → ${site}`); 26 | 27 | try { 28 | // No unknown flags – only the flags your index.js actually supports 29 | // (adjust if you have --headless or --slow in your CLI) 30 | execSync(`node index.js ${site} --slow`, { 31 | stdio: "inherit", 32 | timeout: 300000, // 5-minute max per site (this is Node timeout, not a CLI flag) 33 | }); 34 | 35 | const duration = ((Date.now() - start) / 1000).toFixed(1); 36 | console.log(`SUCCESS – ${duration}s\n`); 37 | passed++; 38 | } catch (err) { 39 | const duration = ((Date.now() - start) / 1000).toFixed(1); 40 | console.log(`FAILED – ${duration}s`); 41 | 42 | // Quick diagnosis 43 | if (err.message.includes("unknown option")) { 44 | console.log( 45 | " → Fix: remove unsupported CLI flags (e.g. --timeout, --headless) from the test script" 46 | ); 47 | } else if ( 48 | err.message.includes("ERR_BLOCKED") || 49 | err.message.includes("Cloudflare") 50 | ) { 51 | console.log(" → Anti-bot / Cloudflare protection"); 52 | } else if (err.message.includes("CAPTCHA")) { 53 | console.log(" → CAPTCHA detected"); 54 | } else { 55 | console.log(" → Other error – check logs above"); 56 | } 57 | console.log(); 58 | failed++; 59 | } 60 | } 61 | 62 | const totalTime = ((Date.now() - startTotal) / 1000 / 60).toFixed(1); 63 | const score = Math.round((passed / sites.length) * 100); 64 | 65 | console.log("═══════════════════════════════════════════════════════════"); 66 | console.log( 67 | `FINAL SCORE: ${score}/100 | ${passed} passed | ${failed} failed | ${totalTime} min` 68 | ); 69 | console.log("═══════════════════════════════════════════════════════════\n"); 70 | 71 | if (score === 100) 72 | console.log("LEGENDARY – You just beat the 10 hardest sites on the planet."); 73 | else if (score >= 70) console.log("ELITE – Top 1% globally."); 74 | else if (score >= 50) console.log("STRONG – Better than 99% of public tools."); 75 | else console.log("Keep improving – anti-bot layer is the next big unlock."); 76 | 77 | process.exit(failed === 0 ? 0 : 1); 78 | -------------------------------------------------------------------------------- /showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevangelist/dembrandt/673ec2b5638253cdb6772073172828bcdac9e668/showcase.png --------------------------------------------------------------------------------