diff --git a/design-media/skills/clone-style/SKILL.md b/design-media/skills/clone-style/SKILL.md new file mode 100644 index 0000000..5b380ac --- /dev/null +++ b/design-media/skills/clone-style/SKILL.md @@ -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 ` | Site live (browser real, CSS computado) | `tokens.json` + screenshots componentes | +| `/clone-style slides ` | Ficheiro PPTX local | `tokens.json` (theme1.xml + slideMasters) | +| `/clone-style apply ` | 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/-/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 ` para listar entradas). + +2. **Extrair em tmp**: `unzip -o -d /tmp/clone-style-pptx-/`. + +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 + + ### Tipografia adicional + - Display: + - Caption: + + ### Espaçamento + Grid base: + Escala: 4, 8, 16, 24, 32, 48, 64 + + ### Sombras + - sm: + - md: + - lg: + + ### Tom visual + +``` + +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/.json` com formato: + +```json +{ + "name": "", + "source": "cloned from ", + "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()` 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. diff --git a/design-media/skills/clone-style/references/__pycache__/extract-pptx-theme.cpython-312.pyc b/design-media/skills/clone-style/references/__pycache__/extract-pptx-theme.cpython-312.pyc new file mode 100644 index 0000000..a390b01 Binary files /dev/null and b/design-media/skills/clone-style/references/__pycache__/extract-pptx-theme.cpython-312.pyc differ diff --git a/design-media/skills/clone-style/references/extract-pptx-theme.py b/design-media/skills/clone-style/references/extract-pptx-theme.py new file mode 100644 index 0000000..886f933 --- /dev/null +++ b/design-media/skills/clone-style/references/extract-pptx-theme.py @@ -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 [--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() diff --git a/design-media/skills/clone-style/references/extract-web-tokens.js b/design-media/skills/clone-style/references/extract-web-tokens.js new file mode 100644 index 0000000..234b246 --- /dev/null +++ b/design-media/skills/clone-style/references/extract-web-tokens.js @@ -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, + } + }; +})();