@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.
- package/CHANGELOG.md +63 -0
- package/README.md +23 -11
- package/package.json +1 -1
- package/src/commands/init.js +20 -16
- package/src/commands/update.js +58 -42
- package/src/lib/ides.js +15 -12
- package/src/lib/manifest.js +89 -0
- package/src/lib/migrations.js +110 -0
- package/templates/.mcp.json +1 -1
- package/templates/AGENTS.md +2 -1
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/copywriting.md +4 -1
- package/templates/_opencrew/core/best-practices/image-design.md +5 -5
- package/templates/_opencrew/core/best-practices/instagram-feed.md +4 -4
- package/templates/_opencrew/core/best-practices/instagram-reels.md +1 -1
- package/templates/_opencrew/core/best-practices/review.md +7 -0
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +8 -8
- package/templates/_opencrew/core/prompts/build.prompt.md +16 -0
- package/templates/_opencrew/core/prompts/discovery.prompt.md +15 -2
- package/templates/_opencrew/core/runner.pipeline.md +86 -11
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +189 -0
- package/templates/_opencrew/core/scripts/verificar/leitura.mjs +104 -0
- package/templates/_opencrew/core/scripts/verificar/regras.mjs +127 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +118 -0
- package/templates/skills/image-ai-generator/SKILL.md +1 -1
- package/templates/skills/image-creator/SKILL.md +2 -2
- package/templates/skills/image-fetcher/SKILL.md +1 -1
- package/templates/skills/opencrew-best-practice-creator/SKILL.md +4 -4
- package/templates/skills/template-designer/SKILL.md +3 -3
- package/templates/skills/template-designer/base-templates/model-a.html +1 -1
- package/templates/skills/template-designer/base-templates/model-b.html +1 -1
- 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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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 (
|
|
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:
|
|
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.,
|
|
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:
|
|
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:
|
|
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:
|
|
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>
|