@aksp/opencrew 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +23 -11
  3. package/package.json +1 -1
  4. package/src/commands/init.js +20 -16
  5. package/src/commands/update.js +58 -42
  6. package/src/lib/ides.js +15 -12
  7. package/src/lib/manifest.js +89 -0
  8. package/src/lib/migrations.js +110 -0
  9. package/templates/.mcp.json +1 -1
  10. package/templates/AGENTS.md +2 -1
  11. package/templates/_opencrew/.opencrew-version +1 -1
  12. package/templates/_opencrew/core/best-practices/copywriting.md +4 -1
  13. package/templates/_opencrew/core/best-practices/image-design.md +5 -5
  14. package/templates/_opencrew/core/best-practices/instagram-feed.md +4 -4
  15. package/templates/_opencrew/core/best-practices/instagram-reels.md +1 -1
  16. package/templates/_opencrew/core/best-practices/review.md +7 -0
  17. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +8 -8
  18. package/templates/_opencrew/core/prompts/build.prompt.md +16 -0
  19. package/templates/_opencrew/core/prompts/discovery.prompt.md +15 -2
  20. package/templates/_opencrew/core/runner.pipeline.md +86 -11
  21. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +189 -0
  22. package/templates/_opencrew/core/scripts/verificar/leitura.mjs +104 -0
  23. package/templates/_opencrew/core/scripts/verificar/regras.mjs +127 -0
  24. package/templates/_opencrew/core/scripts/verificar.mjs +118 -0
  25. package/templates/skills/image-ai-generator/SKILL.md +1 -1
  26. package/templates/skills/image-creator/SKILL.md +2 -2
  27. package/templates/skills/image-fetcher/SKILL.md +1 -1
  28. package/templates/skills/opencrew-best-practice-creator/SKILL.md +4 -4
  29. package/templates/skills/template-designer/SKILL.md +3 -3
  30. package/templates/skills/template-designer/base-templates/model-a.html +1 -1
  31. package/templates/skills/template-designer/base-templates/model-b.html +1 -1
  32. package/templates/skills/template-designer/base-templates/model-c.html +1 -1
@@ -0,0 +1,127 @@
1
+ // Regras do verificador automático (specs/fase-u1-revisor-com-dentes.md §5).
2
+ // Máximos bloqueiam (fácil de corrigir: encurtar); mínimos alertam (podem depender do usuário).
3
+ import { lerFrontmatter, semFrontmatter, semAcento } from './leitura.mjs';
4
+
5
+ const segmentador = new Intl.Segmenter('pt', { granularity: 'grapheme' });
6
+
7
+ /** Bloqueio que só o usuário resolve ([PREENCHER: …]). */
8
+ export const FALTA_INFO = 'Falta informação sua';
9
+
10
+ /** Caracteres visíveis: sem marcadores de negrito/itálico; emoji conta 1. */
11
+ export function contar(texto) {
12
+ const limpo = String(texto).replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
13
+ return [...segmentador.segment(limpo)].length;
14
+ }
15
+
16
+ const contarHashtags = (texto) => (texto.match(/#[\p{L}\p{N}_]+/gu) ?? []).length;
17
+
18
+ const item = (nome, medido, limite, nivel, detalhe = '') => ({ item: nome, medido, limite, nivel, detalhe });
19
+
20
+ function maximo(nome, medido, limite) {
21
+ if (typeof limite !== 'number') return null;
22
+ return item(nome, medido, limite, medido > limite ? 'bloqueio' : 'ok');
23
+ }
24
+
25
+ function minimo(nome, medido, limite) {
26
+ if (typeof limite !== 'number') return null;
27
+ return item(nome, medido, limite, medido < limite ? 'alerta' : 'ok');
28
+ }
29
+
30
+ const NAO_CONTA = /^(mailto:|tel:|https?:\/\/(wa\.me|api\.whatsapp\.com)\/)/i;
31
+
32
+ function contarLinks(corpo, dominio) {
33
+ const urls = [...corpo.matchAll(/\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)].map((m) => m[1]).filter((u) => !NAO_CONTA.test(u));
34
+ const interno = (u) => !/^https?:\/\//i.test(u) || (dominio && new URL(u).hostname.replace(/^www\./, '') === dominio);
35
+ return { internos: urls.filter(interno).length, externos: urls.filter((u) => !interno(u)).length };
36
+ }
37
+
38
+ /** Blog: frontmatter com título → título, meta description e links. */
39
+ export function regrasBlog(texto, limites, dominio) {
40
+ const fm = lerFrontmatter(texto);
41
+ const titulo = fm?.title ?? fm?.titulo;
42
+ if (titulo == null || !limites) return [];
43
+ const meta = fm.meta_description ?? fm.meta_descricao;
44
+ const { internos, externos } = contarLinks(semFrontmatter(texto), dominio);
45
+ return [
46
+ maximo('Título (SEO) — caracteres', contar(titulo), limites.title_max_chars),
47
+ meta != null ? maximo('Meta description — caracteres', contar(meta), limites.meta_description_chars) : null,
48
+ minimo('Links internos', internos, limites.min_internal_links),
49
+ minimo('Links externos', externos, limites.min_external_links),
50
+ ].filter(Boolean);
51
+ }
52
+
53
+ /** Seções por canal (cabeçalhos "Legenda Instagram", "Post LinkedIn", "Tweet"…). */
54
+ export function regrasCanais(secoes, limitesPorFormato) {
55
+ const itens = [];
56
+ for (const s of secoes) {
57
+ const t = semAcento(s.titulo);
58
+ const ig = limitesPorFormato['instagram-feed'];
59
+ if (/instagram/.test(t) && /legenda|caption/.test(t) && ig) {
60
+ itens.push(maximo('Legenda Instagram — caracteres', contar(s.corpo), ig.caption_max_chars));
61
+ itens.push(maximo('Legenda Instagram — hashtags', contarHashtags(s.corpo), ig.hashtags_max));
62
+ } else if (/instagram/.test(t) && /carrossel|carousel/.test(t) && ig) {
63
+ const slides = (s.corpo.match(/^\s*(?:#{3,6}\s*|\*\*\s*)slide\s*\d+/gim) ?? []).length;
64
+ if (slides) itens.push(maximo('Carrossel Instagram — slides', slides, ig.carousel_max_slides));
65
+ }
66
+ const li = limitesPorFormato['linkedin-post'];
67
+ if (/linkedin/.test(t) && !/carrossel|carousel/.test(t) && li) {
68
+ itens.push(maximo('Post LinkedIn — caracteres', contar(s.corpo), li.post_max_chars));
69
+ itens.push(maximo('Post LinkedIn — hashtags', contarHashtags(s.corpo), li.hashtags_max));
70
+ }
71
+ const tw = limitesPorFormato['twitter-post'];
72
+ if (/tweet|twitter/.test(t) && tw) {
73
+ s.corpo.split(/\n\s*\n/).filter((p) => p.trim()).forEach((p, i) => {
74
+ const r = maximo(`Tweet ${i + 1} — caracteres`, contar(p), tw.tweet_max_chars);
75
+ if (r?.nivel === 'bloqueio') itens.push(r);
76
+ });
77
+ }
78
+ }
79
+ return itens.filter(Boolean);
80
+ }
81
+
82
+ const PLACEHOLDERS = [
83
+ /(?:https?:\/\/)?[^\s()[\]<>"']*?(\d)\1{5,}[^\s()[\]<>"']*/g, // 6+ dígitos repetidos (wa.me/5584999999999)
84
+ /\[(?:empresa|cliente|nome|feira|evento|produto|cidade|link|url|telefone|e-?mail|data)\b[^\]]*\](?!\()/giu,
85
+ /lorem ipsum/gi,
86
+ /\bX{3,}\b/g,
87
+ /\{\{[^}]+\}\}/g,
88
+ /\b(?:example\.com|seusite\.com(?:\.br)?|suaempresa\.com(?:\.br)?)\b/gi,
89
+ ];
90
+
91
+ const PRIMEIRA_PESSOA = /(?<!\p{L})(eu|nós|nosso|nossa|nossos|nossas|investimos|atendemos|fizemos|ajudamos|fundamos|começamos|criamos|entregamos|nossa equipe)(?!\p{L})/iu;
92
+ const DADO_CONCRETO = /R\$\s?\d|US\$\s?\d|\d+([.,]\d+)?\s?%|\d+\s+(clientes|empresas|eventos|anos|projetos|pessoas)/iu;
93
+
94
+ // Ano só conta como dado concreto se for passado: "Congresso 2026" (ano atual/futuro) é nome de
95
+ // evento, não afirmação sobre a história da empresa (achado no uso real).
96
+ function temDadoConcreto(frase) {
97
+ if (DADO_CONCRETO.test(frase)) return true;
98
+ const atual = new Date().getFullYear();
99
+ return [...frase.matchAll(/(?<!\d)((?:19|20)\d{2})(?!\d)/g)].some((m) => Number(m[1]) < atual);
100
+ }
101
+
102
+ const citar = (frase) => {
103
+ const limpa = frase.replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
104
+ return limpa.length > 160 ? `${limpa.slice(0, 160).trimEnd()}…` : limpa;
105
+ };
106
+
107
+ /** Checagens que valem para qualquer texto. */
108
+ export function regrasGerais(texto, proibidos) {
109
+ const corpo = semFrontmatter(texto);
110
+ const itens = [];
111
+ for (const rx of PLACEHOLDERS) {
112
+ for (const m of texto.matchAll(rx)) itens.push(item('Placeholder', null, null, 'bloqueio', m[0].replace(/[.,;:!?]+$/, '')));
113
+ }
114
+ for (const m of texto.matchAll(/\[PREENCHER:?\s*([^\]]*)\]/giu)) {
115
+ itens.push(item(FALTA_INFO, null, null, 'bloqueio', m[1].trim() || m[0]));
116
+ }
117
+ const normal = semAcento(texto);
118
+ for (const termo of proibidos) {
119
+ if (normal.includes(semAcento(termo))) itens.push(item('Termo proibido (memória da crew)', null, null, 'bloqueio', termo));
120
+ }
121
+ for (const frase of corpo.split(/(?<=[.!?])\s+|\n+/)) {
122
+ if (PRIMEIRA_PESSOA.test(frase) && temDadoConcreto(frase)) {
123
+ itens.push(item('Afirmação a confirmar', null, null, 'alerta', citar(frase)));
124
+ }
125
+ }
126
+ return itens;
127
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Verificador automático do OpenCrew — mede o texto ANTES do revisor.
3
+ // Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]
4
+ // Os limites vêm do frontmatter `constraints:` dos best-practices (fonte única).
5
+ // Última linha da saída: VERIFICACAO:OK ou VERIFICACAO:BLOQUEADA (o runner lê esta linha).
6
+ // Spec: specs/fase-u1-revisor-com-dentes.md (repositório do OpenCrew).
7
+ import { readFile } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { lerLimites, lerSecoes, lerProibicoes, lerDominioDoSite } from './verificar/leitura.mjs';
12
+ import { regrasBlog, regrasCanais, regrasGerais, FALTA_INFO } from './verificar/regras.mjs';
13
+
14
+ const FORMATOS_DE_CANAL = ['instagram-feed', 'linkedin-post', 'twitter-post'];
15
+
16
+ export async function verificar({ raiz, crew, arquivos, formato = 'blog-post' }) {
17
+ const notas = [];
18
+ const limites = {};
19
+ for (const id of [formato, ...FORMATOS_DE_CANAL]) {
20
+ limites[id] = await lerLimites(raiz, id);
21
+ if (!limites[id]) notas.push(`Formato "${id}" não encontrado em _opencrew/core/best-practices/ — sem limites para ele.`);
22
+ }
23
+ const proibicoes = await lerProibicoes(raiz, crew);
24
+ if (!proibicoes.existe) notas.push('Sem proibições registradas (a crew não tem memories.md).');
25
+ if (proibicoes.semAspas) {
26
+ const n = proibicoes.semAspas;
27
+ notas.push(`${n} ${n === 1 ? 'proibição' : 'proibições'} sem termo entre aspas ${n === 1 ? 'não é verificada' : 'não são verificadas'} automaticamente — escreva o termo entre aspas na memória para virar trava.`);
28
+ }
29
+ const dominio = await lerDominioDoSite(raiz);
30
+
31
+ const resultado = [];
32
+ for (const rel of arquivos) {
33
+ const texto = await readFile(path.join(raiz, rel), 'utf8');
34
+ const itens = [
35
+ ...regrasBlog(texto, limites[formato], dominio),
36
+ ...regrasCanais(lerSecoes(texto), limites),
37
+ ...regrasGerais(texto, proibicoes.termos),
38
+ ];
39
+ resultado.push({ arquivo: rel, itens });
40
+ }
41
+ const todos = resultado.flatMap((a) => a.itens);
42
+ const bloqueios = todos.filter((i) => i.nivel === 'bloqueio');
43
+ const alertas = todos.filter((i) => i.nivel === 'alerta').length;
44
+ // [PREENCHER] só o usuário resolve: não força REJECT (o redator não tem o dado), mas a
45
+ // aprovação final não fecha sem ele.
46
+ const reais = bloqueios.filter((i) => i.item !== FALTA_INFO).length;
47
+ const status = reais ? 'BLOQUEADA' : bloqueios.length ? 'AGUARDANDO_USUARIO' : 'OK';
48
+ return { arquivos: resultado, notas, bloqueios: bloqueios.length, alertas, status };
49
+ }
50
+
51
+ const ROTULO = { bloqueio: '❌ Bloqueio', alerta: '⚠️ Alerta', ok: '✅ OK' };
52
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
53
+
54
+ export function formatarRelatorio(r) {
55
+ const linhas = ['## Verificação automática', ''];
56
+ for (const a of r.arquivos) {
57
+ linhas.push(`### ${a.arquivo}`, '');
58
+ const medidos = a.itens.filter((i) => i.medido != null);
59
+ if (medidos.length) {
60
+ linhas.push('| Item | Medido | Limite | Resultado |', '|---|---|---|---|');
61
+ for (const i of medidos) {
62
+ const op = i.nivel === 'alerta' || /links/i.test(i.item) ? '≥' : '≤';
63
+ linhas.push(`| ${i.item} | ${i.medido} | ${op} ${i.limite} | ${ROTULO[i.nivel]} |`);
64
+ }
65
+ linhas.push('');
66
+ }
67
+ for (const i of a.itens.filter((x) => x.medido == null)) {
68
+ linhas.push(`- ${ROTULO[i.nivel]} — ${i.item}: "${i.detalhe}"`);
69
+ }
70
+ if (!a.itens.length) linhas.push('- ✅ Nada a apontar.');
71
+ linhas.push('');
72
+ }
73
+ if (r.notas.length) linhas.push('**Notas:**', ...r.notas.map((n) => `- ${n}`), '');
74
+ linhas.push(`**Resumo: ${plural(r.bloqueios, 'bloqueio', 'bloqueios')}, ${plural(r.alertas, 'alerta', 'alertas')}**`, '');
75
+ linhas.push(`VERIFICACAO:${r.status}`);
76
+ return linhas.join('\n');
77
+ }
78
+
79
+ function lerArgs(argv) {
80
+ const args = {};
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const m = argv[i].match(/^--(crew|arquivo|formato)$/);
83
+ if (m && i + 1 < argv.length) args[m[1]] = argv[++i];
84
+ }
85
+ return args;
86
+ }
87
+
88
+ const USO = 'Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]';
89
+
90
+ /** @returns {Promise<number>} 0 = verificou (OK ou BLOQUEADA) · 1 = erro de uso */
91
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
92
+ const args = lerArgs(argv);
93
+ if (!args.crew || !args.arquivo) {
94
+ escrever(USO);
95
+ return 1;
96
+ }
97
+ const arquivos = args.arquivo.split(',').map((s) => s.trim()).filter(Boolean);
98
+ for (const rel of [args.crew, ...arquivos]) {
99
+ const abs = path.resolve(cwd, rel);
100
+ if (path.relative(cwd, abs).startsWith('..') || path.isAbsolute(path.relative(cwd, abs))) {
101
+ escrever(`Caminho fora do projeto: ${rel}`);
102
+ return 1;
103
+ }
104
+ }
105
+ const faltando = arquivos.find((rel) => !existsSync(path.join(cwd, rel)));
106
+ if (faltando) {
107
+ escrever(`Arquivo não encontrado: ${faltando}`);
108
+ return 1;
109
+ }
110
+ const r = await verificar({ raiz: cwd, crew: args.crew, arquivos, formato: args.formato || 'blog-post' });
111
+ escrever(formatarRelatorio(r));
112
+ return 0;
113
+ }
114
+
115
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
116
+ if (isMain) {
117
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
118
+ }
@@ -100,7 +100,7 @@ Each item can optionally include a `"reference": "path/to/ref.png"` field.
100
100
  ### Prompt guidelines
101
101
 
102
102
  - Be specific about composition, lighting, style, and mood
103
- - Specify aspect ratio or orientation when relevant (e.g., "portrait 3:4", "landscape 16:9")
103
+ - Specify aspect ratio or orientation when relevant (e.g., "portrait 4:5", "landscape 16:9")
104
104
  - Include "hyper realistic, 4K quality" for photographic styles
105
105
  - Include "clean composition" to avoid cluttered outputs
106
106
  - Avoid requesting text in images — AI models struggle with text rendering
@@ -60,7 +60,7 @@ Use the Visual Renderer when you need to generate production-ready images from H
60
60
 
61
61
  Use these standard dimensions:
62
62
  - Instagram Post: 1080 x 1080
63
- - Instagram Carousel: 1080 x 1440
63
+ - Instagram Carousel: 1080 x 1350
64
64
  - Instagram Story/Reel: 1080 x 1920
65
65
  - Facebook Post: 1200 x 630
66
66
  - Twitter/X Post: 1200 x 675
@@ -86,7 +86,7 @@ Example minimal structure:
86
86
  <meta charset="UTF-8">
87
87
  <style>
88
88
  * { margin: 0; padding: 0; box-sizing: border-box; }
89
- body { width: 1080px; height: 1440px; overflow: hidden; }
89
+ body { width: 1080px; height: 1350px; overflow: hidden; }
90
90
  /* ... your design ... */
91
91
  </style>
92
92
  </head>
@@ -47,7 +47,7 @@ When taking a screenshot:
47
47
  1. Navigate to the URL with `browser_navigate`
48
48
  2. Set viewport: `browser_resize` with width/height for target format
49
49
  - Instagram post: 1080x1080
50
- - Instagram carousel: 1080x1440
50
+ - Instagram carousel: 1080x1350
51
51
  - Story/Reel: 1080x1920
52
52
  - Generic: 1280x720
53
53
  3. Wait for page load (`browser_wait_for` if needed)
@@ -15,7 +15,7 @@ version: "2.0.0"
15
15
 
16
16
  # Best-Practice Creator — Workflow
17
17
 
18
- Use this workflow when creating a new best-practice file for the `_opencrew/core/best-practices/` library.
18
+ Use this workflow when creating a new best-practice file. New and customized best-practices live in `_opencrew/best-practices.local/` — the user's own library, read before `_opencrew/core/best-practices/` and never touched by `npx @aksp/opencrew update` (the core folder is replaced on every update).
19
19
 
20
20
  ## Pre-flight Checks
21
21
 
@@ -67,7 +67,7 @@ For each existing best-practice file whose scope overlaps with the new one:
67
67
 
68
68
  ### 2. Update `_catalog.yaml`
69
69
 
70
- Add a new entry to `_opencrew/core/best-practices/_catalog.yaml` with:
70
+ Add a new entry to `_opencrew/best-practices.local/_catalog.yaml` (create it if missing) with:
71
71
  - `id`: matching the frontmatter `id`
72
72
  - `name`: matching the frontmatter `name`
73
73
  - `whenToUse`: single-line summary of the scope (positive only, no "NOT for")
@@ -77,7 +77,7 @@ Place it under the appropriate section comment (Discipline or Platform best prac
77
77
 
78
78
  ### 3. File placement
79
79
 
80
- Save to `_opencrew/core/best-practices/{id}.md`.
80
+ Save to `_opencrew/best-practices.local/{id}.md`.
81
81
 
82
82
  ### 4. Validation
83
83
 
@@ -93,7 +93,7 @@ Re-read the created file and verify:
93
93
 
94
94
  # Best-Practice Updater — Workflow
95
95
 
96
- Use this workflow when updating best-practice files in the `_opencrew/core/best-practices/` library.
96
+ Use this workflow when updating best-practice files. To change a core best-practice, first copy it from `_opencrew/core/best-practices/` to `_opencrew/best-practices.local/` and edit the copy there (the local copy overrides the core one and survives updates).
97
97
 
98
98
  ## Versioning Rules (Semver)
99
99
 
@@ -46,7 +46,7 @@ Before generating any template, read and internalize the design best practices:
46
46
  - `_opencrew/core/best-practices/image-design.md` — **REQUIRED reading**. Contains platform-specific minimum font sizes, typography rules, spacing guidelines, color palette constraints, contrast requirements, and layout methodology. Every template you generate MUST comply with these rules.
47
47
 
48
48
  Key rules to always follow:
49
- - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1440). Absolute minimum 20px for any readable text on any platform.
49
+ - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1350). Absolute minimum 20px for any readable text on any platform.
50
50
  - **Font weight**: 500 or higher for body text and above.
51
51
  - **Colors**: Maximum 5 colors per design system (primary, secondary, accent, background, text).
52
52
  - **Contrast**: WCAG AA minimum 4.5:1 for all text against background.
@@ -61,7 +61,7 @@ You should also apply general web design best practices: proper white space, vis
61
61
  These rules are NON-NEGOTIABLE. Every template must comply:
62
62
 
63
63
  **Fixed Dimensions (never use height: auto or flexible height):**
64
- - Instagram Carousel: `width: 1080px; height: 1440px` (3:4 portrait)
64
+ - Instagram Carousel: `width: 1080px; height: 1350px` (4:5 portrait)
65
65
  - Instagram Story/Reel: `width: 1080px; height: 1920px` (9:16 portrait)
66
66
  - Instagram Post: `width: 1080px; height: 1080px` (1:1 square)
67
67
  - LinkedIn Post: `width: 1200px; height: 627px` (1.91:1 horizontal)
@@ -155,7 +155,7 @@ When the user approves, create two files:
155
155
 
156
156
  Save to: `crews/{code}/pipeline/data/template-reference.html`
157
157
 
158
- The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1440). This is the literal example the design agent will use.
158
+ The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1350). This is the literal example the design agent will use.
159
159
 
160
160
  ### 2. Visual Identity Rules
161
161
 
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Black background, tweet-style layout with avatar, verified badge, editorial text, contextual image -->
3
3
  <!-- Fonts: Inter (body), weight 400-700 -->
4
4
  <!-- Colors: #000 bg, #fff text, #1D9BF0 verified, #71767B muted, #2a2a2a borders/avatar, #1a1a2e image gradient -->
5
- <div style="width:1080px;height:1440px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
6
6
  <!-- Tweet header -->
7
7
  <div style="display:flex;gap:24px;align-items:center;">
8
8
  <div style="width:80px;height:80px;border-radius:50%;background:#2a2a2a;flex-shrink:0;"></div>
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Warm cream background, bold uppercase title, numbered cards with warm gradient progression -->
3
3
  <!-- Fonts: Montserrat 900 (title), Inter 500-700 (body) -->
4
4
  <!-- Colors: #FDF6EE bg, #E85D2A primary orange, #D4742A/#C05A1A/#A04010 gradient steps, #FFF5E6-#D07030 card backgrounds -->
5
- <div style="width:1080px;height:1440px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
6
6
  <!-- Title -->
7
7
  <div style="text-align:center;">
8
8
  <h1 style="font-size:58px;font-weight:900;line-height:1.15;margin:0;text-transform:uppercase;font-family:'Montserrat',sans-serif;">
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Purple-dark gradient, data metric cards, decorative glow effects -->
3
3
  <!-- Fonts: Montserrat 900 (title, metrics), Inter 500 (body, labels) -->
4
4
  <!-- Colors: gradient #0f0326-#1a0a3e-#0d0d0d bg, #F59E0B yellow, #EC4899 pink, #7C3AED purple, #4ADE80 green accents -->
5
- <div style="width:1080px;height:1440px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
6
6
  <!-- Glow effects -->
7
7
  <div style="position:absolute;top:-120px;right:-120px;width:480px;height:480px;background:radial-gradient(circle,#7C3AED33,transparent 70%);pointer-events:none;"></div>
8
8
  <div style="position:absolute;bottom:-100px;left:-100px;width:400px;height:400px;background:radial-gradient(circle,#F59E0B22,transparent 70%);pointer-events:none;"></div>