@polymorphism-tech/morph-spec 7.5.0 → 7.7.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 (61) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +18 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +12 -4
  7. package/framework/MORPH.md +18 -10
  8. package/framework/agents.json +13 -5
  9. package/framework/commands/morph-preflight.md +10 -5
  10. package/framework/hooks/README.md +8 -10
  11. package/framework/hooks/claude-code/core/gate-guard.js +36 -16
  12. package/framework/hooks/claude-code/core/protect-spec-files.js +20 -5
  13. package/framework/hooks/claude-code/core/tasks-json-guard.js +81 -0
  14. package/framework/hooks/shared/tasks-validator.js +68 -0
  15. package/framework/mcp/registry.json +5 -48
  16. package/framework/rubrics/backend.json +3 -3
  17. package/framework/rubrics/default.json +3 -3
  18. package/framework/rubrics/frontend.json +3 -3
  19. package/framework/rules/infrastructure-standards.md +4 -4
  20. package/framework/skills/core/create-umbraco-lp/SKILL.md +22 -2
  21. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +84 -5
  22. package/framework/skills/core/morph-apply/SKILL.md +53 -40
  23. package/framework/skills/core/morph-brainstorming/SKILL.md +7 -12
  24. package/framework/skills/core/morph-eval/SKILL.md +10 -8
  25. package/framework/skills/core/morph-implement/SKILL.md +27 -21
  26. package/framework/skills/core/morph-plan/SKILL.md +12 -21
  27. package/framework/skills/core/morph-proposal/SKILL.md +3 -8
  28. package/framework/skills/core/morph-review/SKILL.md +30 -14
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +80 -8
  32. package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
  33. package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
  34. package/framework/standards/ai-agents/setup.md +3 -0
  35. package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
  36. package/framework/standards/ai-agents/workflows.md +190 -187
  37. package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
  38. package/framework/standards/integration/mcp/mcp-tools.md +38 -43
  39. package/framework/templates/README.md +7 -7
  40. package/framework/templates/REGISTRY.json +14 -14
  41. package/framework/templates/context/CONTEXT.md +3 -3
  42. package/framework/templates/docs/proposal.md +2 -2
  43. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
  44. package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
  45. package/framework/templates/infrastructure/github/README.md +6 -6
  46. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
  47. package/package.json +1 -1
  48. package/src/commands/project/doctor.js +36 -1
  49. package/src/commands/state/approve.js +29 -4
  50. package/src/commands/state/score.js +88 -0
  51. package/src/commands/tasks/dag.js +68 -0
  52. package/src/dashboard/scanner/rubrics-scanner.js +1 -1
  53. package/src/lib/generators/recap-generator.js +1 -1
  54. package/src/lib/tasks/dispatch-planner.js +201 -0
  55. package/src/lib/validators/score-aggregator.js +3 -3
  56. package/src/scripts/setup-infra.js +42 -0
  57. package/src/utils/agents-installer.js +142 -10
  58. package/src/utils/hooks-installer.js +13 -5
  59. package/framework/rubrics/eval-rubric.json +0 -66
  60. package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
  61. package/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +0 -31
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tasks-json-guard.js — PostToolUse hook (validate-and-warn, never block)
4
+ *
5
+ * Event: PostToolUse | Matcher: Write|Edit
6
+ *
7
+ * After any Write/Edit that touches a tasks.json under .morph/features/,
8
+ * re-reads the file from disk and validates the schema (status enum, effort
9
+ * enum). Invalid → injects a warning so the LLM fixes the file immediately;
10
+ * valid → logs one activity line and stays silent.
11
+ *
12
+ * The production failure this guards: a leaf sub-agent wrote
13
+ * status:"completed" (not in the enum — the schema accepts "done") into
14
+ * tasks.json and nothing flagged it. The blocking pre-write validation for
15
+ * post-Gate-2 edits lives in protect-spec-files.js (PreToolUse); this
16
+ * PostToolUse pass also covers pre-gate authoring (morph-plan) and anything
17
+ * that landed on disk regardless.
18
+ *
19
+ * Fail-open: exits 0 on any error.
20
+ */
21
+
22
+ import { readFileSync } from 'fs';
23
+ import { isAbsolute, join } from 'path';
24
+ import { stateExists } from '../../shared/state-reader.js';
25
+ import { injectContext, pass } from '../../shared/hook-response.js';
26
+ import { logHookActivity } from '../../shared/activity-logger.js';
27
+ import { getFilePath } from '../../shared/payload-utils.js';
28
+ import { isFeaturePath, getBasename } from '../../shared/phase-utils.js';
29
+ import { VALID_TASK_STATUS, validateTasksDoc } from '../../shared/tasks-validator.js';
30
+
31
+ function readStdin() {
32
+ try {
33
+ return JSON.parse(readFileSync(0, 'utf8') || '{}');
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function projectDir() {
40
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
41
+ }
42
+
43
+ try {
44
+ const cwd = projectDir();
45
+ if (!stateExists(cwd)) pass();
46
+
47
+ const payload = readStdin();
48
+ const filePath = getFilePath(payload);
49
+ if (!filePath) pass();
50
+ if (!isFeaturePath(filePath)) pass();
51
+ if (getBasename(filePath) !== 'tasks.json') pass();
52
+
53
+ const absPath = isAbsolute(filePath) ? filePath : join(cwd, filePath);
54
+
55
+ let doc;
56
+ try {
57
+ doc = JSON.parse(readFileSync(absPath, 'utf-8'));
58
+ } catch {
59
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
60
+ injectContext(
61
+ '⚠️ [morph-spec] tasks.json INVÁLIDO — o arquivo gravado não parseia como JSON. ' +
62
+ 'Corrija o arquivo antes de continuar.'
63
+ );
64
+ }
65
+
66
+ const { valid, errors } = validateTasksDoc(doc);
67
+ if (!valid) {
68
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
69
+ injectContext(
70
+ '⚠️ [morph-spec] tasks.json com schema inválido após o write:\n' +
71
+ errors.map(e => ` • ${e}`).join('\n') +
72
+ `\nStatus válidos: ${VALID_TASK_STATUS.join(', ')}. Corrija antes de marcar a task como done.`
73
+ );
74
+ }
75
+
76
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'ok', cwd);
77
+ pass();
78
+ } catch {
79
+ // Fail-open
80
+ process.exit(0);
81
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * tasks-validator.js — minimal hook-side schema validation for tasks.json.
3
+ *
4
+ * Mirrors VALID_STATUS / VALID_EFFORT from src/lib/tasks/tasks-json-parser.js.
5
+ * Hooks are installed standalone into target projects (.morph/framework/hooks/),
6
+ * so they cannot import from src/ — the enum lives here, duplicated on purpose
7
+ * and covered by tests on both sides.
8
+ */
9
+
10
+ export const VALID_TASK_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
11
+ export const VALID_TASK_EFFORT = ['S', 'M', 'L', 'XL'];
12
+
13
+ /**
14
+ * Validate the parts of a tasks.json document that hooks care about:
15
+ * a tasks[] array whose entries carry a valid status (and effort, when set).
16
+ * The production failure this guards: a leaf agent writing status "completed"
17
+ * (not in the enum) without any validation.
18
+ *
19
+ * @param {object} doc - parsed tasks.json
20
+ * @returns {{ valid: boolean, errors: string[] }}
21
+ */
22
+ export function validateTasksDoc(doc) {
23
+ if (!doc || typeof doc !== 'object' || !Array.isArray(doc.tasks)) {
24
+ return { valid: false, errors: ['tasks.json must be an object with a tasks[] array'] };
25
+ }
26
+
27
+ const errors = [];
28
+ for (const task of doc.tasks) {
29
+ const id = task?.id ?? '?';
30
+ if (!VALID_TASK_STATUS.includes(task?.status)) {
31
+ errors.push(`task '${id}': invalid status '${task?.status}' (valid: ${VALID_TASK_STATUS.join(', ')})`);
32
+ }
33
+ if (task?.effort !== undefined && !VALID_TASK_EFFORT.includes(task.effort)) {
34
+ errors.push(`task '${id}': invalid effort '${task.effort}' (valid: ${VALID_TASK_EFFORT.join(', ')})`);
35
+ }
36
+ }
37
+ return { valid: errors.length === 0, errors };
38
+ }
39
+
40
+ /**
41
+ * Planned-vs-real outputs divergence: tasks whose `outputs` changed relative
42
+ * to the previous document but whose `notes` is still empty. The divergence
43
+ * itself is legitimate (outputs is a mutable progress field) — shipping it
44
+ * without an explanatory note is what gets flagged.
45
+ *
46
+ * @param {object} oldDoc - parsed previous tasks.json
47
+ * @param {object} newDoc - parsed proposed tasks.json
48
+ * @returns {string[]} human-readable violation messages (empty = OK)
49
+ */
50
+ export function outputsDivergenceViolations(oldDoc, newDoc) {
51
+ const violations = [];
52
+ const oldById = new Map((oldDoc?.tasks ?? []).map(t => [t.id, t]));
53
+
54
+ for (const task of newDoc?.tasks ?? []) {
55
+ const old = oldById.get(task.id);
56
+ if (!old) continue;
57
+ const outputsChanged =
58
+ JSON.stringify(old.outputs ?? []) !== JSON.stringify(task.outputs ?? []);
59
+ const hasNote = typeof task.notes === 'string' && task.notes.trim().length > 0;
60
+ if (outputsChanged && !hasNote) {
61
+ violations.push(
62
+ `task '${task.id}': outputs diverged from the plan but 'notes' is empty — ` +
63
+ `record why in 'notes' in the same edit`
64
+ );
65
+ }
66
+ }
67
+ return violations;
68
+ }
@@ -58,50 +58,6 @@
58
58
  "setupGuide": null
59
59
  }
60
60
  },
61
- "github": {
62
- "phases": ["tasks", "implement"],
63
- "tools": ["get_repo", "search_code", "create_issue", "create_pull_request", "search_issues"],
64
- "detection": "mcpServers includes github",
65
- "fallback": "Bash `gh` CLI commands",
66
- "usage": "Repo metadata, code search, issue creation, PR management",
67
- "healthCheck": {
68
- "toolPattern": "github",
69
- "testCall": "Call any read-only GitHub tool (e.g., search_repositories or get_repo) to verify connectivity",
70
- "testParams": {},
71
- "successIndicator": "Returns repository data or search results without auth errors",
72
- "failureHint": "GitHub MCP not responding. If using Docker, ensure Docker Desktop is running. Verify GITHUB_PERSONAL_ACCESS_TOKEN env var is set. Ensure the token has not expired and has the required scopes (repo, read:org)."
73
- },
74
- "install": {
75
- "autoInstall": false,
76
- "transport": "stdio",
77
- "config": {
78
- "type": "stdio",
79
- "command": "docker",
80
- "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
81
- "env": {
82
- "GITHUB_PERSONAL_ACCESS_TOKEN": ""
83
- }
84
- },
85
- "credentials": [
86
- {
87
- "name": "GitHub Personal Access Token",
88
- "envVar": "GITHUB_PERSONAL_ACCESS_TOKEN",
89
- "secret": true,
90
- "helpUrl": "https://github.com/settings/tokens"
91
- }
92
- ],
93
- "prerequisites": [
94
- {
95
- "name": "Docker",
96
- "checkCommand": "docker --version",
97
- "installUrl": "https://docs.docker.com/get-docker/"
98
- }
99
- ],
100
- "stackRelevance": ["*"],
101
- "warnings": [],
102
- "setupGuide": "Official GitHub MCP Server (github/github-mcp-server). Requires Docker."
103
- }
104
- },
105
61
  "vercel": {
106
62
  "phases": ["implement"],
107
63
  "tools": ["search_docs", "list_projects", "get_deployments", "get_deployment_logs", "manage_env_vars"],
@@ -234,7 +190,8 @@
234
190
  {
235
191
  "name": "Gemini API Key",
236
192
  "envVar": "GEMINI_API_KEY",
237
- "source": "Google AI Studio (https://aistudio.google.com/apikey)"
193
+ "secret": true,
194
+ "helpUrl": "https://aistudio.google.com/apikey"
238
195
  }
239
196
  ],
240
197
  "prerequisites": ["Google AI Studio API key"],
@@ -247,8 +204,8 @@
247
204
  "phaseMatrix": {
248
205
  "proposal": ["context7"],
249
206
  "uiux": ["playwright", "context7", "stitch", "gemini-image"],
250
- "design": ["context7", "github"],
251
- "tasks": ["context7", "github"],
252
- "implement": ["context7", "playwright", "github", "vercel", "gemini-image"]
207
+ "design": ["context7"],
208
+ "tasks": ["context7"],
209
+ "implement": ["context7", "playwright", "vercel", "gemini-image"]
253
210
  }
254
211
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "backend",
3
- "version": "1.0.0",
4
- "description": "Backend-focused rubric. Higher weight on architecture, security, and package health.",
3
+ "version": "1.1.0",
4
+ "description": "Backend-focused rubric. Higher weight on architecture, security, and package health. Deterministic-validator rubric — approve threshold aligned to the single bar 9 from MORPH.md §0.",
5
5
  "categories": {
6
6
  "functionality": {
7
7
  "weight": 0.25,
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "thresholds": {
38
- "approve": 8.0,
38
+ "approve": 9.0,
39
39
  "retry": 6.0
40
40
  }
41
41
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "default",
3
- "version": "1.0.0",
4
- "description": "Default rubric for general-purpose projects. Balanced weights across all categories.",
3
+ "version": "1.1.0",
4
+ "description": "Default rubric for general-purpose projects. Balanced weights across all categories. Deterministic-validator rubric — approve threshold aligned to the single bar 9 from MORPH.md §0 (the LLM eval rubrics live in framework/evals/rubrics/*.md).",
5
5
  "categories": {
6
6
  "functionality": {
7
7
  "weight": 0.30,
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "thresholds": {
33
- "approve": 8.0,
33
+ "approve": 9.0,
34
34
  "retry": 6.0
35
35
  }
36
36
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "frontend",
3
- "version": "1.0.0",
4
- "description": "Frontend-focused rubric. Higher weight on design system compliance and UI quality.",
3
+ "version": "1.1.0",
4
+ "description": "Frontend-focused rubric. Higher weight on design system compliance and UI quality. Deterministic-validator rubric — approve threshold aligned to the single bar 9 from MORPH.md §0.",
5
5
  "categories": {
6
6
  "functionality": {
7
7
  "weight": 0.25,
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "thresholds": {
33
- "approve": 8.0,
33
+ "approve": 9.0,
34
34
  "retry": 6.0
35
35
  }
36
36
  }
@@ -3,14 +3,14 @@ paths:
3
3
  - "**/Dockerfile*"
4
4
  - "**/docker-compose*"
5
5
  - "**/.github/**"
6
- - "**/easypanel*"
6
+ - "**/coolify*"
7
7
  stacks:
8
8
  - docker
9
9
  ---
10
10
 
11
11
  # Infrastructure Standards
12
12
 
13
- @.morph/framework/standards/infrastructure/docker/easypanel-deploy.md
13
+ @.morph/framework/standards/infrastructure/docker/coolify-deploy.md
14
14
 
15
15
  ## Infrastructure Templates
16
16
 
@@ -19,9 +19,9 @@ stacks:
19
19
  | .NET API Dockerfile | `.morph/framework/templates/infrastructure/docker/dockerfile-api.dockerfile` | Multi-stage .NET |
20
20
  | Next.js Dockerfile | `.morph/framework/templates/infrastructure/docker/dockerfile-web.dockerfile` | Multi-stage Next.js |
21
21
  | Docker Compose | `.morph/framework/templates/infrastructure/docker/docker-compose.template.yml` | Local dev env |
22
- | EasyPanel Config | `.morph/framework/templates/infrastructure/docker/easypanel.template.json` | Deployment config |
22
+ | Coolify Config | `.morph/framework/templates/infrastructure/docker/coolify.template.json` | Deployment config |
23
23
  | .NET Build CI | `.morph/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs` | CI workflow |
24
24
  | Docker Push CI | `.morph/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs` | Build & push |
25
- | Deploy EasyPanel | `.morph/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs` | CD webhook |
25
+ | Deploy Coolify | `.morph/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs` | CD via SSH |
26
26
 
27
27
  Read the template and adapt to your project structure.
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "7.5.0"
7
+ cliVersion: "7.7.0"
8
8
  ---
9
9
 
10
10
  # create-umbraco-lp
@@ -82,17 +82,31 @@ UMBRACO_ADMIN_PASSWORD=<gerada>
82
82
  UMBRACO_GLOBAL_ID=<uuid-gerado>
83
83
  ALLOWED_ORIGINS=http://localhost:3000
84
84
  REVALIDATE_SECRET=<gerado>
85
+ FRONTEND_URL=http://localhost:3000
85
86
  LEADS_INTEGRATION=<integração-escolhida>
86
87
  ```
87
88
 
89
+ `FRONTEND_URL` (junto com `REVALIDATE_SECRET`) alimenta o `RevalidateOnPublishHandler` do
90
+ backend: a cada publish no Umbraco, ele chama `/api/revalidate` do frontend sozinho, em
91
+ código — não depende de configurar nada em Settings > Webhooks no backoffice. **Em
92
+ produção, atualize `FRONTEND_URL` para o domínio público real do frontend.**
93
+
88
94
  E gera `backend/appsettings.Development.json` com o bloco Unattended install do Umbraco (usando o email/senha computados pelo script). Isso significa que um projeto recém-scaffoldado **auto-instala no `dotnet run`** — sem necessidade de rodar o instalador UI manualmente.
89
95
 
90
96
  E escreve `frontend/.env.local` com:
91
97
  ```
92
98
  NEXT_PUBLIC_UMBRACO_URL=https://localhost:44324
99
+ NEXT_PUBLIC_SITE_URL=http://localhost:3000
93
100
  REVALIDATE_SECRET=<mesmo-secret>
94
101
  ```
95
102
 
103
+ `NEXT_PUBLIC_SITE_URL` é usada por `robots.ts`/`sitemap.ts`. **Em produção, precisa ser
104
+ setada como Build Variable no Coolify** — o `Dockerfile` do template
105
+ declara `ARG NEXT_PUBLIC_SITE_URL` (e `ARG NEXT_PUBLIC_UMBRACO_URL`) justamente para isso;
106
+ sem marcar como Build Variable, a env var não chega ao `next build` e o `robots.txt`/
107
+ `sitemap.xml` publicam o domínio placeholder do template (`minha-landing-page.com.br`) em
108
+ produção — já aconteceu num projeto real.
109
+
96
110
  > **Nota de overwrite:** se `.env` já existe, `new-lp.ps1` prompta (Read-Host) para sobrescrever — portanto, uma execução não-interativa em um diretório que já tem `.env` vai bloquear na prompt. Execute em um scaffold fresco, ou responda à prompt interativamente.
97
111
 
98
112
  > **Nota importante:** `ALLOWED_ORIGINS` é gravado com `http://localhost:3000` pelo script independentemente do que foi informado no passo 1. Se o usuário especificou um domínio de produção, a forma de aplicá-lo depende do modo de execução:
@@ -229,9 +243,15 @@ Antes de fazer deploy ou entregar o projeto ao cliente:
229
243
  - [ ] Delivery API responde com o node `lpHome` populado: `/umbraco/delivery/api/v2/content?contentType=lpHome&take=1`.
230
244
  - [ ] `.env` contém `ALLOWED_ORIGINS` com o domínio de produção correto (separado por vírgula se múltiplos).
231
245
  - [ ] `UMBRACO_GLOBAL_ID` é único para este projeto (gerado pelo script — não reutilizar de outro projeto).
232
- - [ ] SEO: preencher título, descrição e OG image no backoffice (campos do node `lpHome`).
246
+ - [ ] SEO: preencher no backoffice, aba SEO do node `lpHome` — Page Title, Meta Title, Meta Description, **Social Image** (1200x630, sem ela não há `og:image` e o link não gera preview no WhatsApp/redes), Canonical Url (só a URL, sem tag HTML).
247
+ - [ ] Favicon: preencher o campo **Favicon** no backoffice (aba Configuration) — `app/icon.tsx`/`apple-icon.tsx` já leem esse campo dinamicamente; sem ele, cai no favicon estático genérico do template.
248
+ - [ ] `NEXT_PUBLIC_SITE_URL` **e** `NEXT_PUBLIC_UMBRACO_URL` setadas como **Build Variable** no provedor (não só runtime) — conferir `robots.txt`/`sitemap.xml` em produção com o domínio real após o primeiro deploy.
249
+ - [ ] `FRONTEND_URL` + `REVALIDATE_SECRET` setadas no backend em produção (revalidação automática pós-publish via `RevalidateOnPublishHandler`) — testar: publicar algo no backoffice e conferir no log do backend a linha `RevalidateOnPublish: cache do frontend invalidado após publish.`
250
+ - [ ] No Index: confirmar que está **desligado** no backoffice antes de ir pra produção (o campo é opt-in pra esconder do Google, não deveria vir ligado por padrão).
233
251
  - [ ] Variáveis sensíveis (`UMBRACO_ADMIN_PASSWORD`, `REVALIDATE_SECRET`, tokens de integração) não estão commitadas no repositório (`.env` deve estar no `.gitignore`).
234
252
  - [ ] Em produção com Docker Compose: `docker-compose up --build` e confirmar que backend e frontend sobem com as variáveis do `.env`.
253
+ - [ ] `browserslist` presente em `frontend/package.json` — já vem do template; só confirmar que não foi removido/sobrescrito durante a customização.
254
+ - [ ] Se o cliente vai subir fotos de câmera/celular pelo Media Picker (galeria, hero, etc.): confirmar que `ResizeLargeMediaUploadsHandler` está registrado em `backend/Program.cs` (já vem do template) — sem ele, upload cru de 10+ MB pode virar 500 intermitente sob concorrência no otimizador do Next. Setar `MEDIA_BACKFILL_SECRET` em produção se for preciso reprocessar mídia já publicada (ver seção "Mídia" do README do template).
235
255
 
236
256
  > **Nota Docker — `LEADS_INTEGRATION` e `SEED_DEMO_CONTENT`:** o `docker-compose.yml` **não** injeta essas duas variáveis no container do backend. Mesmo que estejam no `.env`, o seed sempre inicializa `leadIntegration` como `"none"` no boot Docker. Isso é esperado: o editor deve configurar a integração manualmente no backoffice após o primeiro boot. Se precisar seeding automático da integração via Docker, adicione a variável na seção `environment` do serviço `backend` em `docker-compose.yml` manualmente.
237
257
 
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "7.5.0"
7
+ cliVersion: "7.7.0"
8
8
  ---
9
9
 
10
10
  # migrate-lp-to-umbraco
@@ -100,6 +100,16 @@ O objetivo desta etapa não é "entender" a LP, é **capturar tudo o que é nece
100
100
  - Registrar o `sha256` de cada asset (vira a base da paridade de checksum no passo 6).
101
101
  - **Não reencodar nem otimizar** nenhum asset — qualquer reencode quebra a paridade.
102
102
 
103
+ **SEO/GEO da origem — capturar para semear, não perder no lift:** a paridade visual não
104
+ cobre isso sozinha — um lift perfeito que esquece o SEO da origem entrega uma LP idêntica
105
+ visualmente mas invisível pra buscadores/IA no dia 1. Capturar da origem:
106
+ - `<title>`, `<meta name="description">`, `<link rel="canonical">`.
107
+ - Open Graph: `og:title`, `og:description`, `og:image` (baixar a imagem, idealmente 1200x630).
108
+ - JSON-LD (`<script type="application/ld+json">`) — schema completo, se existir.
109
+ - Favicon (todos os tamanhos/formatos disponíveis) e `apple-touch-icon`.
110
+ - `robots.txt` e `sitemap.xml` da origem — confirmar se há regras além do default (bots de IA bloqueados? outras URLs?).
111
+ - `llms.txt` da origem, se existir.
112
+
103
113
  **Formulário de captação** — campos presentes, validação no frontend, endpoint atual, integração de CRM/leads.
104
114
 
105
115
  **Origem apenas por URL (fallback):** se não houver código-fonte, scrapear o HTML renderizado, extrair o CSS computado dos elementos e baixar todos os assets referenciados (incluindo fontes). O lift parte do markup renderizado + CSS computado.
@@ -268,7 +278,22 @@ sectionNode.SetValue("videoFundo", mediaJson);
268
278
 
269
279
  Coloque os assets de seed em `backend/seed-assets/` (imagens, vídeos, ícones da LP de origem).
270
280
 
271
- **O que vai no SeedContentHandler:** textos de inauguração, mídia local (arquivos em `seed-assets/`), ordem das seções, configuração inicial. O conteúdo fica editável no backoffice após o primeiro boot — o seed é apenas o estado inicial.
281
+ **Semear também o SEO/GEO capturado no passo 1** — não deixar em branco pra "o editor preencher depois" (isso já causou um site indo pro ar sem `og:image`, com favicon genérico e apontando pro domínio errado no sitemap num projeto real):
282
+
283
+ ```csharp
284
+ content.SetValue("pageTitleSeo", "<título da origem>");
285
+ content.SetValue("titleSeo", "<og:title da origem>");
286
+ content.SetValue("descriptionSeo", "<meta description da origem>");
287
+ content.SetValue("canonicalUrlSeo", "<URL canônica de produção>"); // só a URL, sem tag HTML
288
+ content.SetValue("noIndexSeo", false); // é Mandatory=false a partir do fix de 2026-07-02 — não deixar true
289
+ content.SetValue("jsonLDSchemaSeo", OrigemJsonLd); // JSON-LD capturado da origem, se existir
290
+ // imageSeo (og:image) e favicon: importar como media (mesmo padrão de CreateOrGetMedia)
291
+ // e setar via MediaPicker3, igual ao padrão de mídia acima.
292
+ ```
293
+
294
+ `imageSeo` (1200x630) e `favicon` alimentam `app/icon.tsx`/`apple-icon.tsx` e o `<meta property="og:image">` automaticamente — não precisam de nenhum código extra no frontend, só o seed correto.
295
+
296
+ **O que vai no SeedContentHandler:** textos de inauguração, mídia local (arquivos em `seed-assets/`), ordem das seções, configuração inicial, **e o SEO/GEO capturado no passo 1**. O conteúdo fica editável no backoffice após o primeiro boot — o seed é apenas o estado inicial.
272
297
 
273
298
  **O que NÃO vai no SeedContentHandler:** tokens de integração (GHL API Key, webhook URL). Esses ficam no nó `leadsConfig` que o operador preenche diretamente no backoffice após o boot (ver passo 5).
274
299
 
@@ -298,12 +323,58 @@ Copiar as variáveis de design da origem para `frontend/src/app/globals.css` no
298
323
  }
299
324
  ```
300
325
 
301
- Atualizar os `<link>` de Google Fonts em `frontend/src/app/layout.tsx` para carregar as fontes corretas.
326
+ **Fontes — auto-hospedar, nunca `<link>` externo pro Google Fonts:** um `<link rel="stylesheet"
327
+ href="https://fonts.googleapis.com/css2?...">` no `<head>` é render-blocking (round-trip
328
+ externo antes de qualquer paint) e, combinado com `font-display: swap`, causa CLS quando a
329
+ fonte troca depois do primeiro paint — foi 1.680ms de render-blocking estimado e 0.221 de CLS
330
+ medidos num caso real (Barone_LP). **Não usar `next/font/google`** aqui (diferente do passo 4
331
+ de `create-umbraco-lp`, que pode usar): o nome de família que ele gera é escopado/diferente do
332
+ literal da origem, e o gate de paridade (passo 6b.2) compara `font-family` computado
333
+ byte-a-byte contra a origem — `next/font` quebra esse check.
334
+
335
+ O fix correto, que preserva a paridade:
336
+ 1. Buscar a CSS real do Google pra descobrir as URLs `.woff2` (`curl` na mesma URL que iria no
337
+ `<link>`) — geralmente **um arquivo por família+estilo já cobre todos os pesos estáticos**
338
+ usados (Google costuma reaproveitar o mesmo arquivo pra weight 400/500/600/700 de uma fonte
339
+ não-variável). Baixar só o subset `latin` (`unicode-range: U+0000-00FF, ...`) — cobre toda
340
+ acentuação do português (á, ã, ç, é... estão em U+00C0-00FF).
341
+ 2. Salvar os `.woff2` em `frontend/public/fonts/`.
342
+ 3. Declarar `@font-face` em `globals.css` (ou no arquivo de tema) com o **mesmo `font-family`
343
+ literal da origem** (`'Cormorant'`, `'Jost'`, etc. — não o nome gerado por uma lib) e
344
+ `font-display: optional` (não `swap`) — `optional` só troca pro custom font se ele chegar
345
+ quase instantaneamente; senão fica no fallback pra aquela visita, evitando o reflow visível
346
+ que gerou o CLS.
347
+ 4. `<link rel="preload" href="/fonts/arquivo.woff2" as="font" type="font/woff2" crossOrigin="anonymous">`
348
+ no `<head>` do `layout.tsx`, sem nenhum `<link>` pro domínio do Google.
349
+
350
+ Verificar ao final: `getComputedStyle(el).fontFamily` no elemento de heading do hero deve
351
+ retornar **a mesma string literal que a origem** (ex.: `"Cormorant, Georgia, serif"`) — só a
352
+ origem do arquivo mudou (de `fonts.gstatic.com` pra same-origin), não o nome da família.
302
353
 
303
354
  #### 4d. Componentes frontend dos módulos novos
304
355
 
305
356
  Para cada módulo novo, criar o componente em `frontend/src/components/modules/NomeSection.tsx` **a partir do componente da origem copiado verbatim** — colar o JSX/markup/classes/CSS da origem e trocar só os literais de conteúdo por `data.*`:
306
357
 
358
+ > **Exceção deliberada ao verbatim — imagens de mídia do Umbraco:** o componente `Picture`
359
+ > lifted da origem provavelmente emite um `<img>` cru. Pra qualquer `src` que vier do Media
360
+ > Picker (URL do backend, editável no backoffice — diferente de um asset estático em
361
+ > `/public`), usar `next/image` (`fill`, com o container já dimensionado) desde o primeiro
362
+ > lift, não um `<img>` cru. Isso **não** quebra o gate de paridade (passo 6b.3 só faz checksum
363
+ > de assets em `/public`, nunca de URLs de mídia) — pelo contrário, sem isso qualquer foto que
364
+ > o cliente trocar depois no backoffice chega crua (fotos de câmera/celular fácil 10+ MB) e
365
+ > vira lento ou 500 sob concorrência (ver seção "Mídia" no README do template).
366
+ >
367
+ > **Pitfall a checar nesse lift:** `next/image fill` aplica `position/height/width` **inline**
368
+ > no `<img>` renderizado — isso vence qualquer regra de classe (`.minha-grade img { height:
369
+ > 116%; margin-top: -8% }`, por exemplo) que a origem use pra efeitos de oversize/parallax/zoom
370
+ > em cima de um `<img>` cru, mesmo que a regra continue no CSS lifted verbatim. Se a origem tem
371
+ > algum truque desses (imagem deliberadamente maior que o container + margin negativo, comum
372
+ > em seções com parallax scroll-scrub), a troca pra `fill` abre um vão visível onde a "sobra"
373
+ > da imagem deveria estar. Fix: `!important` na propriedade de classe que está sendo vencida
374
+ > (normalmente só `height`) — é uma das poucas justificativas legítimas pra `!important`, já
375
+ > que é o único jeito de vencer um inline style que você não controla diretamente. Checar isso
376
+ > na hora do lift, não esperar aparecer como bug visual depois.
377
+
307
378
  ```tsx
308
379
  // Padrão: componente com 'use client', recebe { data: SectionProps },
309
380
  // faz cast para o tipo tipado, e lê props via data.property.
@@ -365,6 +436,12 @@ Objetivo: **provar** que a LP migrada é idêntica à origem. Com o lift verbati
365
436
 
366
437
  **Hierarquia do gate:** os checks **determinísticos** (6b) são o **bloqueio**. O **screenshot diff** (6c) é **corroboração secundária** — nunca o bloqueio único, porque font hinting/antialiasing diferem entre ambientes mesmo com CSS byte-idêntico.
367
438
 
439
+ > Os dois pontos do passo 4c (fontes auto-hospedadas) e 4d (`next/image fill` pra mídia do
440
+ > Umbraco) não são drift de paridade — são a paridade **certa** pedindo um detalhe extra de
441
+ > implementação. O checksum de assets (6b.3) só cobre `/public`; o estilo computado (6b.2)
442
+ > continua batendo porque `font-family` fica literal. Nenhum dos dois deveria fazer o gate
443
+ > falhar quando bem aplicados.
444
+
368
445
  #### 6a. Boot + checklist manual (sanidade)
369
446
 
370
447
  ```bash
@@ -403,7 +480,9 @@ A migração é "exata" quando **todos** são verdade:
403
480
  - **6c screenshot diff** abaixo da tolerância em todos os breakpoints (mobile/tablet/desktop).
404
481
  - Todas as seções renderizam com conteúdo real (não placeholders) e na ordem da origem.
405
482
  - Formulário envia lead com sucesso para a integração configurada (testar com dado real em staging; `--demo-seed` popula a home com módulos de demonstração via `SEED_DEMO_CONTENT=true`, não envia lead de teste).
406
- - Backoffice: editar um campo de texto, publicar — a mudança reflete na página (revalidação por tag ou ISR) **sem** alterar o markup/estilo renderizado.
483
+ - Backoffice: editar um campo de texto, publicar — a mudança reflete na página **em produção, sem precisar de deploy manual** (o `RevalidateOnPublishHandler` do backend chama `/api/revalidate` sozinho; só funciona com `FRONTEND_URL`/`REVALIDATE_SECRET` setados) e **sem** alterar o markup/estilo renderizado.
484
+ - SEO/GEO da origem migrado, não perdido: `curl` a home de produção e conferir `<title>`, `<meta description>`, `og:image`, canonical e JSON-LD batendo com o que foi capturado no passo 1 — e que o favicon do browser não é o ícone genérico do template.
485
+ - `robots.txt`/`sitemap.xml` de produção apontam pro domínio real (não o placeholder `minha-landing-page.com.br` do template) — só acontece se `NEXT_PUBLIC_SITE_URL` foi setada como Build Variable no provedor.
407
486
  - Nenhuma chave de API ou token aparece em arquivo versionado.
408
487
 
409
488
  ## Anti-padrões
@@ -415,7 +494,7 @@ A migração é "exata" quando **todos** são verdade:
415
494
  - Não saltear o smoke-boot (passo 3) — problemas de ambiente devem ser detectados antes de portar conteúdo.
416
495
  - Não reutilizar `customHtmlSection` para tudo — módulos dedicados permitem edição granular no backoffice e ISR por tag.
417
496
  - **Não reinterpretar layout/CSS nem "limpar" o markup** — portar verbatim; o módulo Umbraco só embrulha o mesmo markup da origem. Rebuild = drift.
418
- - **Não reencodar nem otimizar assets** na migração — quebra a paridade de checksum (6b) e altera o render.
497
+ - **Não reencodar nem otimizar assets da origem** (o que vai pra `/public`/`seed-assets`) na migração — quebra a paridade de checksum (6b) e altera o render. Isso **não** se aplica a imagens que vêm do Media Picker do Umbraco (ver passo 4d) — essas não são asset da origem, não entram no checksum, e otimizá-las via `next/image` é o comportamento correto, não uma violação de paridade.
419
498
  - **Não assumir o tema do template** — portar o Tailwind/`@theme` (e as fontes) da origem; a mesma classe utilitária renderiza diferente entre versões/temas.
420
499
  - **Não tratar o screenshot diff como gate único** — o bloqueio são os checks determinísticos (6b); o diff visual é secundário.
421
500