feat(design-media): /clone-style — extracção de design tokens (web + pptx)
Resolve dor real: clonar estilos visuais de sites e slides para propostas. Tentativas anteriores (Penpot, scrapers HTML, Figma import) falharam porque tentavam reproduzir layouts. Esta skill extrai tokens (cores, fontes, espaçamento, raios, sombras) e alimenta gerador (Stitch / design-engine). Modos: - /clone-style web <url>: extract-web-tokens.js via chrome real (CSS computado) - /clone-style slides <pptx>: extract-pptx-theme.py (theme1.xml + slideMasters) - /clone-style apply <tokens.json>: mapeia para Stitch / design-engine / pptx Validado: PPTX (Calibri/Calibri Light + 6 accent colors do Office default theme). Web: aguarda primeiro teste end-to-end com browser real. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: clone-style
|
||||
description: Clona o estilo visual de um site ou apresentação extraindo design tokens (cores, tipografia, espaçamento, raios, sombras) e gerando JSON compatível com Stitch, design-engine e pptx-generator. Usar quando "clonar estilo", "clone style", "extrair design", "copiar visual", "tokens design", "clone web", "clone slides", "design tokens".
|
||||
---
|
||||
|
||||
# /clone-style — Extrair Design Tokens de Sites e Slides
|
||||
|
||||
## Princípio fundamental
|
||||
|
||||
**Não tentes recriar visualmente — extrai o sistema.** Captura os tokens (cores, fontes, espaçamentos, raios) e alimenta uma ferramenta generativa. A fidelidade vem do sistema, não da imitação pixel a pixel.
|
||||
|
||||
## Modos
|
||||
|
||||
| Comando | Fonte | Output |
|
||||
|---|---|---|
|
||||
| `/clone-style web <url>` | Site live (browser real, CSS computado) | `tokens.json` + screenshots componentes |
|
||||
| `/clone-style slides <pptx_path>` | Ficheiro PPTX local | `tokens.json` (theme1.xml + slideMasters) |
|
||||
| `/clone-style apply <tokens.json>` | Design tokens já extraídos | Aplicação no Stitch / design-engine / pptx-generator |
|
||||
|
||||
---
|
||||
|
||||
## Modo 1 — Web
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Abrir URL no Chrome real** via `mcp__claude-in-chrome__navigate` (preferido — captura sessões logged-in se necessário) ou `mcp__chrome-devtools__navigate_page`.
|
||||
|
||||
2. **Aguardar render completo** (`wait_for` "load" + 2s extra para JS).
|
||||
|
||||
3. **Injectar script de extracção** via `mcp__claude-in-chrome__javascript_tool` ou `mcp__chrome-devtools__evaluate_script`. O script está em `references/extract-web-tokens.js`. Cola-o dentro do tool call.
|
||||
|
||||
4. **O script devolve JSON estruturado** com:
|
||||
- `colors`: paleta deduplicada + ranking por frequência (top 12)
|
||||
- `fonts`: famílias únicas + tamanhos + pesos + line-heights
|
||||
- `spacing`: gaps/paddings/margins agrupados (encontra grid base 4px/8px)
|
||||
- `borderRadius`: pequeno/médio/grande/full (clusters)
|
||||
- `shadows`: box-shadows únicas
|
||||
- `componentes`: HTML+CSS de buttons, cards, nav, hero (capturados como referência)
|
||||
|
||||
5. **Capturar screenshots** dos componentes detectados via `mcp__chrome-devtools__take_screenshot` ou `mcp__claude-in-chrome__computer` (full + por componente).
|
||||
|
||||
6. **Mapear para Stitch** (ver secção "Mapeamento Stitch" abaixo) — cores → primary seed, fonte mais usada → enum match, raio dominante → ROUND_*.
|
||||
|
||||
7. **Guardar** em `~/.claude-work/clone-style/<dominio>-<timestamp>/tokens.json` + `screenshots/`.
|
||||
|
||||
### Comando exemplo
|
||||
|
||||
```
|
||||
/clone-style web https://linear.app
|
||||
```
|
||||
|
||||
Output esperado:
|
||||
- `~/.claude-work/clone-style/linear-app-20260407/tokens.json`
|
||||
- `~/.claude-work/clone-style/linear-app-20260407/screenshots/full.png`
|
||||
- `~/.claude-work/clone-style/linear-app-20260407/screenshots/hero.png`
|
||||
- `~/.claude-work/clone-style/linear-app-20260407/screenshots/buttons.png`
|
||||
- Resumo no terminal: paleta + fontes + sugestão de aplicação
|
||||
|
||||
---
|
||||
|
||||
## Modo 2 — Slides (PPTX)
|
||||
|
||||
PPTX é um ZIP de XML. Toda a informação de tema está em `ppt/theme/theme1.xml` e `ppt/slideMasters/slideMaster1.xml`.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Verificar input** existe e é PPTX válido (`unzip -l <file>` para listar entradas).
|
||||
|
||||
2. **Extrair em tmp**: `unzip -o <file> -d /tmp/clone-style-pptx-<ts>/`.
|
||||
|
||||
3. **Parsear `theme1.xml`** (Python ou xmllint):
|
||||
```python
|
||||
import xml.etree.ElementTree as ET
|
||||
ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
|
||||
tree = ET.parse('ppt/theme/theme1.xml')
|
||||
root = tree.getroot()
|
||||
|
||||
# Cores tema (accent1-6, dark1, dark2, light1, light2, hyperlink, followedHyperlink)
|
||||
colors = {}
|
||||
for child in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme'):
|
||||
for color_node in child:
|
||||
name = color_node.tag.split('}')[1]
|
||||
srgb = color_node.find('a:srgbClr', ns)
|
||||
sysclr = color_node.find('a:sysClr', ns)
|
||||
if srgb is not None:
|
||||
colors[name] = '#' + srgb.get('val')
|
||||
elif sysclr is not None:
|
||||
colors[name] = '#' + sysclr.get('lastClr', '000000')
|
||||
|
||||
# Fontes (major = headlines, minor = body)
|
||||
fonts = {}
|
||||
for fs in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}fontScheme'):
|
||||
major = fs.find('a:majorFont/a:latin', ns)
|
||||
minor = fs.find('a:minorFont/a:latin', ns)
|
||||
if major is not None: fonts['headline'] = major.get('typeface')
|
||||
if minor is not None: fonts['body'] = minor.get('typeface')
|
||||
```
|
||||
|
||||
4. **Parsear `slideMaster1.xml`** para layouts dominantes (background, posições, sizes default).
|
||||
|
||||
5. **Output `tokens.json`** no mesmo formato do Modo 1 (para que `apply` seja unificado).
|
||||
|
||||
### Comando exemplo
|
||||
|
||||
```
|
||||
/clone-style slides /home/ealmeida/Downloads/template-cliente.pptx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modo 3 — Apply
|
||||
|
||||
Recebe `tokens.json` e aplica numa das 3 ferramentas:
|
||||
|
||||
### A) Stitch (web/UI)
|
||||
|
||||
**Limitação importante:** Stitch só aceita um conjunto restrito de tokens (ver schema `mcp__stitch__create_design_system`):
|
||||
- 1 `customColor` (seed) + opcionais `overridePrimaryColor`/`overrideSecondaryColor`/`overrideTertiaryColor`/`overrideNeutralColor`
|
||||
- `headlineFont` / `bodyFont` / `labelFont` de **enum fixo** (29 Google Fonts: INTER, ROBOTO, MANROPE, GEIST, etc.)
|
||||
- `roundness`: ROUND_4 / ROUND_8 / ROUND_12 / ROUND_FULL
|
||||
- `colorMode`: LIGHT / DARK
|
||||
- `designMd`: markdown livre para tudo o que não cabe nos campos acima
|
||||
|
||||
**Mapeamento Stitch:**
|
||||
|
||||
```
|
||||
tokens.colors.dominant → customColor (seed)
|
||||
tokens.colors.top[1..3] → overridePrimary/Secondary/Tertiary
|
||||
tokens.colors.neutral → overrideNeutralColor
|
||||
|
||||
tokens.fonts.headline.family
|
||||
→ match com enum Stitch (fuzzy: "Helvetica" → INTER, "Söhne" → INTER, etc.)
|
||||
→ headlineFont
|
||||
|
||||
tokens.fonts.body.family
|
||||
→ match enum → bodyFont
|
||||
|
||||
tokens.borderRadius.dominant
|
||||
→ 4px → ROUND_4
|
||||
→ 8-10px → ROUND_8
|
||||
→ 12-14px → ROUND_12
|
||||
→ ≥16px ou full → ROUND_FULL
|
||||
|
||||
tokens.background.dark? → colorMode: DARK, else LIGHT
|
||||
|
||||
# Tudo o resto vai para designMd:
|
||||
designMd: |
|
||||
## Estilo extraído de <fonte>
|
||||
|
||||
### Tipografia adicional
|
||||
- Display: <font> <size> <weight>
|
||||
- Caption: <font> <size> <weight>
|
||||
|
||||
### Espaçamento
|
||||
Grid base: <Npx>
|
||||
Escala: 4, 8, 16, 24, 32, 48, 64
|
||||
|
||||
### Sombras
|
||||
- sm: <box-shadow>
|
||||
- md: <box-shadow>
|
||||
- lg: <box-shadow>
|
||||
|
||||
### Tom visual
|
||||
<descrição em prosa: minimalista, denso, colorido, monocromático, etc.>
|
||||
```
|
||||
|
||||
Depois chama `mcp__stitch__create_design_system` com este payload.
|
||||
|
||||
### B) design-engine (brand pack)
|
||||
|
||||
Cria um JSON brand pack em `/media/ealmeida/Dados/Hub/04-Recursos/Design/brands/<nome>.json` com formato:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "<nome>",
|
||||
"source": "cloned from <url|pptx>",
|
||||
"extracted_at": "2026-04-07",
|
||||
"colors": { "primary": "#...", "secondary": "#...", "neutral": "#...", "accent": "#..." },
|
||||
"fonts": { "headline": "Inter", "body": "Inter" },
|
||||
"borderRadius": "8px",
|
||||
"tone": "minimalista, profissional, denso"
|
||||
}
|
||||
```
|
||||
|
||||
Depois `mcp__design-engine__brand_load(<nome>)` para usar em `generate_image`.
|
||||
|
||||
### C) pptx-generator (slides)
|
||||
|
||||
Aplica os tokens via XML manipulation no template Descomplicar (ver `/pptx-generator`). Substitui:
|
||||
- Cores accent1-6 do `theme1.xml` pelos extraídos
|
||||
- Fontes major/minor pelas extraídas
|
||||
- Mantém layouts/positioning do template Descomplicar (não copia layouts da fonte original — só estilo)
|
||||
|
||||
---
|
||||
|
||||
## Mapeamento de fontes (fuzzy → Stitch enum)
|
||||
|
||||
Quando `tokens.fonts.headline.family` não está no enum Stitch, mapear:
|
||||
|
||||
| Família detectada | Stitch enum |
|
||||
|---|---|
|
||||
| Helvetica, Helvetica Neue, Arial, sans-serif genérico | INTER |
|
||||
| Söhne, Inter, system-ui | INTER |
|
||||
| Roboto, Roboto Flex | (não existe — usar INTER) |
|
||||
| SF Pro, -apple-system | INTER |
|
||||
| GT Walsheim, Geist, Mona Sans | GEIST |
|
||||
| Manrope | MANROPE |
|
||||
| Plus Jakarta, Jakarta | PLUS_JAKARTA_SANS |
|
||||
| DM Sans | DM_SANS |
|
||||
| IBM Plex Sans | IBM_PLEX_SANS |
|
||||
| Sora | SORA |
|
||||
| Times, serif genérico | NEWSREADER |
|
||||
| Garamond, EB Garamond | EB_GARAMOND |
|
||||
| Playfair, Domine | DOMINE |
|
||||
|
||||
Se nenhum match óbvio, **default INTER** (mais neutro). Adicionar nota em `designMd` com a fonte original detectada.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Não fazer | Razão |
|
||||
|---|---|
|
||||
| Tentar reproduzir layout pixel a pixel | Impossível e gera "AI slop" |
|
||||
| Usar `cssstats.com` ou scrapers HTML estáticos | Falham em sites JS-rendered (Next.js, React, etc.) |
|
||||
| Importar HTML inteiro como Stitch screen | Stitch não importa HTML; gera de prompt+tokens |
|
||||
| Aplicar Stitch design-system sem `customColor` válido | Validation error — `customColor` é obrigatório |
|
||||
| Esquecer de usar `designMd` para tokens fora do enum | Perde-se 50% da informação extraída |
|
||||
|
||||
---
|
||||
|
||||
## Validação (critérios para "OK")
|
||||
|
||||
Antes de declarar a clonagem completa:
|
||||
1. ✅ `tokens.json` existe e tem `colors`, `fonts`, `borderRadius` no mínimo
|
||||
2. ✅ Pelo menos 1 screenshot guardado
|
||||
3. ✅ Mapeamento Stitch produz `customColor` válido (hex format)
|
||||
4. ✅ Pelo menos 1 fonte mapeada para enum Stitch
|
||||
5. ✅ `designMd` tem ≥3 secções (tipografia, espaçamento, tom)
|
||||
6. ✅ Se modo `apply`: validação visual pelo utilizador antes de marcar "concluído"
|
||||
|
||||
---
|
||||
|
||||
## Exemplos práticos
|
||||
|
||||
### Clonar Linear
|
||||
|
||||
```
|
||||
/clone-style web https://linear.app
|
||||
```
|
||||
Esperado: paleta dominante azul/roxo, fontes Inter ou similar, raios 6-8px, dark mode default.
|
||||
|
||||
### Clonar template cliente
|
||||
|
||||
```
|
||||
/clone-style slides ~/Downloads/cliente-deck.pptx
|
||||
```
|
||||
Esperado: cores accent do tema PPTX, fontes major/minor, proposta de paleta unificada.
|
||||
|
||||
### Aplicar a novo projecto Stitch
|
||||
|
||||
```
|
||||
/clone-style apply ~/.claude-work/clone-style/linear-app-20260407/tokens.json
|
||||
```
|
||||
Cria design system Stitch + brand pack design-engine.
|
||||
|
||||
---
|
||||
|
||||
## Referências internas
|
||||
|
||||
- `references/extract-web-tokens.js` — script JS de extracção CSS computado
|
||||
- `references/extract-pptx-theme.py` — script Python para parsear theme1.xml
|
||||
- `~/.claude-work/clone-style/` — cache de extracções (timestamped)
|
||||
|
||||
---
|
||||
|
||||
**Versão:** 1.0.0 | **Data:** 2026-04-07 | **Autor:** Descomplicar (sessão 5)
|
||||
**Origem:** Necessidade real — clonagem de estilos em propostas comerciais. Substitui tentativas falhadas com Penpot, scrapers HTML, screenshots+AI, Figma import.
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract-pptx-theme.py — extrai design tokens de um ficheiro PPTX
|
||||
|
||||
Uso:
|
||||
python3 extract-pptx-theme.py <caminho.pptx> [--output tokens.json]
|
||||
|
||||
Devolve JSON no mesmo formato do extract-web-tokens.js para que /clone-style apply
|
||||
seja unificado entre os 2 modos.
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
|
||||
|
||||
|
||||
def extract_theme(pptx_path: Path) -> dict:
|
||||
if not pptx_path.exists():
|
||||
raise FileNotFoundError(f"Não encontrado: {pptx_path}")
|
||||
|
||||
with zipfile.ZipFile(pptx_path) as z:
|
||||
# Listar themes (pode haver múltiplos; usar o primeiro)
|
||||
theme_files = sorted([n for n in z.namelist() if n.startswith('ppt/theme/theme') and n.endswith('.xml')])
|
||||
if not theme_files:
|
||||
raise ValueError("PPTX sem theme — formato inválido ou corrompido")
|
||||
|
||||
theme_xml = z.read(theme_files[0])
|
||||
root = ET.fromstring(theme_xml)
|
||||
|
||||
# Extrair cores do clrScheme
|
||||
colors = {}
|
||||
for clr_scheme in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme'):
|
||||
for color_node in clr_scheme:
|
||||
name = color_node.tag.split('}')[1] # ex: 'accent1', 'dk1', 'lt1'
|
||||
srgb = color_node.find('a:srgbClr', NS)
|
||||
sysclr = color_node.find('a:sysClr', NS)
|
||||
if srgb is not None:
|
||||
colors[name] = '#' + srgb.get('val')
|
||||
elif sysclr is not None:
|
||||
# sysClr usa o lastClr como fallback (cor resolvida)
|
||||
colors[name] = '#' + sysclr.get('lastClr', '000000')
|
||||
|
||||
# Extrair fontes do fontScheme
|
||||
fonts = {'headline': None, 'body': None}
|
||||
for fs in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}fontScheme'):
|
||||
major = fs.find('a:majorFont/a:latin', NS)
|
||||
minor = fs.find('a:minorFont/a:latin', NS)
|
||||
if major is not None:
|
||||
fonts['headline'] = major.get('typeface')
|
||||
if minor is not None:
|
||||
fonts['body'] = minor.get('typeface')
|
||||
|
||||
# Mapear cores PPTX → estrutura unificada
|
||||
accent_keys = ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']
|
||||
accents = [colors[k] for k in accent_keys if k in colors]
|
||||
|
||||
primary = colors.get('accent1') or accents[0] if accents else '#000000'
|
||||
secondary = colors.get('accent2')
|
||||
tertiary = colors.get('accent3')
|
||||
neutral = colors.get('lt1') or colors.get('dk1') or '#ffffff'
|
||||
background = colors.get('lt1') or colors.get('bg1') or '#ffffff'
|
||||
|
||||
is_dark = colors.get('lt1', '#ffffff').lstrip('#').upper() != 'FFFFFF'
|
||||
|
||||
return {
|
||||
'url': str(pptx_path),
|
||||
'title': pptx_path.stem,
|
||||
'source': 'pptx',
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'colors': {
|
||||
'top': [{'value': v, 'count': 1} for v in accents],
|
||||
'backgrounds': [{'value': background, 'count': 1}],
|
||||
'dominant': primary,
|
||||
'raw_scheme': colors,
|
||||
},
|
||||
'fonts': {
|
||||
'families': [
|
||||
{'value': fonts['headline'], 'count': 1, 'role': 'headline'},
|
||||
{'value': fonts['body'], 'count': 1, 'role': 'body'},
|
||||
],
|
||||
'sizes': [],
|
||||
'weights': [],
|
||||
'lineHeights': [],
|
||||
},
|
||||
'borderRadius': [],
|
||||
'shadows': [],
|
||||
'spacing': {'gridBaseGuess': 8, 'top': []},
|
||||
'components': {},
|
||||
'meta': {
|
||||
'colorMode': 'DARK' if is_dark else 'LIGHT',
|
||||
'theme_file': theme_files[0],
|
||||
'accent_count': len(accents),
|
||||
},
|
||||
'mapping_hint': {
|
||||
'stitch_customColor': primary,
|
||||
'stitch_overrideSecondary': secondary,
|
||||
'stitch_overrideTertiary': tertiary,
|
||||
'stitch_overrideNeutral': neutral,
|
||||
'note': 'PPTX só fornece cores e fontes — borderRadius/shadows/spacing têm de ser definidos manualmente em designMd',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
pptx = Path(sys.argv[1]).expanduser().resolve()
|
||||
|
||||
output_path = None
|
||||
if '--output' in sys.argv:
|
||||
idx = sys.argv.index('--output')
|
||||
if idx + 1 < len(sys.argv):
|
||||
output_path = Path(sys.argv[idx + 1]).expanduser().resolve()
|
||||
|
||||
tokens = extract_theme(pptx)
|
||||
js = json.dumps(tokens, indent=2, ensure_ascii=False)
|
||||
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(js, encoding='utf-8')
|
||||
print(f"✅ Tokens guardados em {output_path}", file=sys.stderr)
|
||||
else:
|
||||
print(js)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
// extract-web-tokens.js
|
||||
// Injectar via mcp__claude-in-chrome__javascript_tool ou mcp__chrome-devtools__evaluate_script
|
||||
// Devolve JSON com cores, fontes, espaçamentos, raios, sombras e amostras de componentes.
|
||||
// Funciona em qualquer site (incluindo JS-rendered) porque usa getComputedStyle().
|
||||
|
||||
(() => {
|
||||
const all = document.querySelectorAll('*');
|
||||
const colorCounts = {};
|
||||
const fontFamilies = {};
|
||||
const fontSizes = {};
|
||||
const fontWeights = {};
|
||||
const lineHeights = {};
|
||||
const radii = {};
|
||||
const shadows = {};
|
||||
const spacings = {};
|
||||
const bgColors = {};
|
||||
|
||||
const norm = (v) => (v || '').trim();
|
||||
const inc = (obj, k) => { if (!k || k === 'none' || k === '0px') return; obj[k] = (obj[k] || 0) + 1; };
|
||||
|
||||
// Helper: rgb(a) → hex (ignora transparente)
|
||||
const toHex = (rgb) => {
|
||||
if (!rgb || rgb === 'transparent' || rgb === 'rgba(0, 0, 0, 0)') return null;
|
||||
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (!m) return null;
|
||||
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
|
||||
if (a < 0.1) return null;
|
||||
const h = (n) => parseInt(n).toString(16).padStart(2, '0');
|
||||
return '#' + h(m[1]) + h(m[2]) + h(m[3]);
|
||||
};
|
||||
|
||||
for (const el of all) {
|
||||
if (!(el instanceof Element)) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
|
||||
// Cores
|
||||
inc(colorCounts, toHex(cs.color));
|
||||
const bg = toHex(cs.backgroundColor);
|
||||
if (bg) {
|
||||
inc(bgColors, bg);
|
||||
inc(colorCounts, bg);
|
||||
}
|
||||
inc(colorCounts, toHex(cs.borderColor));
|
||||
|
||||
// Fontes
|
||||
inc(fontFamilies, norm(cs.fontFamily));
|
||||
inc(fontSizes, norm(cs.fontSize));
|
||||
inc(fontWeights, norm(cs.fontWeight));
|
||||
inc(lineHeights, norm(cs.lineHeight));
|
||||
|
||||
// Raios
|
||||
inc(radii, norm(cs.borderRadius));
|
||||
|
||||
// Sombras
|
||||
if (cs.boxShadow && cs.boxShadow !== 'none') inc(shadows, cs.boxShadow);
|
||||
|
||||
// Espaçamentos (paddings + margins + gap)
|
||||
['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'gap', 'rowGap', 'columnGap'].forEach((p) => {
|
||||
inc(spacings, norm(cs[p]));
|
||||
});
|
||||
}
|
||||
|
||||
// Top N por frequência
|
||||
const topN = (obj, n) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ value: k, count: v }));
|
||||
|
||||
// Detectar grid base (espaçamento mais comum)
|
||||
const numericSpacings = Object.entries(spacings)
|
||||
.map(([k, v]) => [parseFloat(k), v])
|
||||
.filter(([n]) => !isNaN(n) && n > 0 && n < 200)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const gridBase = numericSpacings[0]?.[0] || 8;
|
||||
|
||||
// Capturar samples HTML de componentes-chave (pelo selector)
|
||||
const sample = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
return {
|
||||
html: el.outerHTML.substring(0, 800),
|
||||
computed: {
|
||||
bg: toHex(getComputedStyle(el).backgroundColor),
|
||||
color: toHex(getComputedStyle(el).color),
|
||||
radius: getComputedStyle(el).borderRadius,
|
||||
padding: getComputedStyle(el).padding,
|
||||
font: getComputedStyle(el).fontFamily,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
timestamp: new Date().toISOString(),
|
||||
colors: {
|
||||
top: topN(colorCounts, 12),
|
||||
backgrounds: topN(bgColors, 6),
|
||||
dominant: topN(colorCounts, 1)[0]?.value || null,
|
||||
},
|
||||
fonts: {
|
||||
families: topN(fontFamilies, 5),
|
||||
sizes: topN(fontSizes, 8),
|
||||
weights: topN(fontWeights, 6),
|
||||
lineHeights: topN(lineHeights, 6),
|
||||
},
|
||||
borderRadius: topN(radii, 6),
|
||||
shadows: topN(shadows, 5),
|
||||
spacing: {
|
||||
gridBaseGuess: gridBase,
|
||||
top: topN(spacings, 12),
|
||||
},
|
||||
components: {
|
||||
button: sample('button, .btn, [class*="button" i]'),
|
||||
card: sample('.card, [class*="card" i]'),
|
||||
nav: sample('nav, header, [class*="nav" i]'),
|
||||
hero: sample('main > section:first-child, .hero, [class*="hero" i]'),
|
||||
},
|
||||
meta: {
|
||||
colorMode: getComputedStyle(document.body).backgroundColor.includes('255') ? 'LIGHT' : 'DARK',
|
||||
lang: document.documentElement.lang || 'unknown',
|
||||
viewport: document.querySelector('meta[name=viewport]')?.content || null,
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user