diff --git a/.github/workflows/design-check.yml b/.github/workflows/design-check.yml
new file mode 100644
index 0000000..c4faaa3
--- /dev/null
+++ b/.github/workflows/design-check.yml
@@ -0,0 +1,267 @@
+name: Design check
+
+# Duas perguntas que antes eram dois workflows: o que mudou no design, e se o
+# código acompanha o design. Um job, um agente, um comentário. O porquê de
+# cada decisão está no §14 do DESIGN-SYSTEM.md; a lógica, em scripts/ — este
+# arquivo só encadeia.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ paths:
+ - "design/pendev/**"
+ - "design/DESIGN-SYSTEM.md"
+ - "design/screen-routes.json"
+ - "app/**"
+ - "components/**"
+ - "scripts/**"
+ - ".github/workflows/design-check.yml"
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: design-check-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+env:
+ PEN_FILE: design/pendev/youtube-channel.pen
+ WORK: /tmp/check
+ BASE_REF: ${{ github.base_ref }}
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ # O que roda depende do que o PR mudou, de ser rascunho e de ser fork
+ # (fork não recebe secret nem token de escrita). Ver scripts/check-gate.sh.
+ - name: Decidir o que roda
+ id: gate
+ env:
+ DRAFT: ${{ github.event.pull_request.draft }}
+ FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
+ run: ./scripts/check-gate.sh "origin/$BASE_REF" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ # >= 0.3.5 obrigatório: em 0.3.2 um .pen com fills de imagem relativos
+ # carrega VAZIO sem erro fatal. scripts/pen-export.sh checa e falha.
+ - name: Instalar pen.dev CLI
+ if: steps.gate.outputs.pen == 'true'
+ run: npm install -g @pen.dev/cli && pen version
+
+ # O .pen referencia ./assets/* relativamente, então a versão da base
+ # precisa da árvore inteira — um worktree, não um `git show` solto.
+ - name: Worktree da base
+ if: steps.gate.outputs.design == 'true'
+ run: git worktree add --detach ../base "origin/$BASE_REF"
+
+ - name: Diff do design
+ id: design
+ if: steps.gate.outputs.design == 'true'
+ env:
+ PEN_CLI_KEY: ${{ secrets.PEN_CLI_KEY }}
+ RENDER: ${{ steps.gate.outputs.render }}
+ run: ./scripts/design-diff.sh "../base/$PEN_FILE" "$PEN_FILE" "$WORK/design"
+
+ # A evidência do agente, cada arquivo autoridade sobre uma coisa (§14).
+ # Os digests do head já saíram do passo anterior quando ele rodou.
+ - name: Evidência do design para a auditoria
+ id: evidence
+ if: ${{ !cancelled() && steps.gate.outputs.deep == 'true' }}
+ env:
+ PEN_CLI_KEY: ${{ secrets.PEN_CLI_KEY }}
+ run: |
+ mkdir -p "$WORK/audit"
+ if [ -f "$WORK/design/head/tokens.json" ]; then
+ cp "$WORK"/design/head/{tokens.json,components.json,inventory.txt,screens.tsv} "$WORK/audit/"
+ else
+ ./scripts/pen-digest.sh "$PEN_FILE" "$WORK/audit"
+ ./scripts/pen-screens.sh "$PEN_FILE" > "$WORK/audit/screens.tsv"
+ fi
+ PEN_NODES="$(paste -sd';' "$WORK/audit/inventory.txt")" \
+ ./scripts/pen-export.sh "$PEN_FILE" "$WORK/audit/components.html" 1 html-tailwind
+ ./scripts/pen-outline.py "$PEN_FILE" > "$WORK/audit/screens.json"
+ ls -la "$WORK/audit"
+
+ # Divergência é achado, não falha: o passo só falha se não conseguiu medir.
+ - name: Medir a app nas páginas do mapa
+ id: numeric
+ if: ${{ !cancelled() && steps.evidence.outcome == 'success' }}
+ continue-on-error: true
+ run: |
+ code=0
+ ./scripts/measure-app.sh "$WORK/audit/components.html" "$WORK/audit/screens.tsv" > "$WORK/numeric.md" || code=$?
+ cat "$WORK/numeric.md"
+ exit $code
+
+ - name: Varredura mecânica
+ id: scan
+ if: ${{ !cancelled() && steps.gate.outputs.scan == 'true' }}
+ run: |
+ inventory="$WORK/audit/inventory.txt"
+ [ -f "$inventory" ] || inventory="$WORK/design/head/inventory.txt"
+ [ -f "$inventory" ] || inventory=""
+ INVENTORY="$inventory" ./scripts/drift-scan.sh "origin/$BASE_REF" > "$WORK/scan.md"
+ cat "$WORK/scan.md"
+
+ # O agente NÃO posta: escreve human.md e findings.json, e o passo
+ # "Publicar" publica. Assim a publicação é determinística, deduplica entre
+ # pushes, e o resultado mecânico sai mesmo se o agente falhar.
+ #
+ # github_token é obrigatório aqui. Sem ele a action troca OIDC pelo token
+ # do app do Claude, e essa troca exige o workflow idêntico ao do branch
+ # padrão: em PR que mexe neste arquivo, a action sai em 2s com "success"
+ # sem rodar nada. Como o agente não posta, o token do job basta — e ele já
+ # está disponível para qualquer workflow de PR do próprio repositório.
+ - uses: anthropics/claude-code-action@v1
+ id: agent
+ if: ${{ !cancelled() && steps.gate.outputs.deep == 'true' }}
+ continue-on-error: true
+ with:
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
+ github_token: ${{ github.token }}
+ prompt: |
+ Audite este PR contra o design e descreva a mudança de design. NÃO
+ poste nada e não use ferramenta de comentário: escreva os dois
+ arquivos abaixo, e o workflow publica.
+
+ As regras estão em design/DESIGN-SYSTEM.md, já carregado via
+ CLAUDE.md. As dez regras auditadas estão numeradas no §12: use
+ exatamente esses números.
+
+ ## Saída
+
+ 1. /tmp/check/findings.json — SEMPRE, mesmo que vazio ([]):
+
+ [{"path": "components/chip.tsx", "line": 9, "rule": 10,
+ "title": "padding lateral 16px no design, 12px no código",
+ "body": "O Chip usa $space-4 no .pen. Troque `px-3` por `px-4` (§4)."}]
+
+ - path: relativo à raiz do repositório. line: a linha do arquivo
+ ATUAL onde a correção acontece — no código, nunca no .pen.
+ - rule: o número da regra do §12. title: uma linha. body: a
+ correção, citando a seção do doc.
+ - Arquivo que não mudou neste PR também vale: o workflow decide se
+ o achado vira comentário inline ou item do resumo. Não procure
+ uma linha do diff para encaixar o achado.
+ - Achado é só o que você verificou. Ocorrência do scan que é uso
+ correto não entra.
+
+ 2. /tmp/check/human.md — SÓ se /tmp/check/design/summary.md existir.
+ Sem título (o workflow já põe um). No máximo 12 linhas, em
+ português, dizendo o que mudou NO DESIGN,
+ em pixels: "o padding lateral do Chip vai de 12px para 16px", não
+ "$space-3 -> $space-4". Tela adicionada ou removida, e a rota que
+ isso cria ou apaga (design/screen-routes.json); composição que
+ mudou. NÃO diga o que mudar no código — isso é achado, e só existe
+ se o código de fato não acompanha. Se o design não mudou de
+ verdade, uma linha dizendo isso.
+
+ ## Ordem — ela existe para você não gastar turnos
+
+ 1. /tmp/check/scan.md primeiro. Cada ocorrência é candidata: abra só
+ aquele arquivo e decida se é violação ou uso correto. Seção que
+ não aparece passou: não reinvestigue.
+ 2. /tmp/check/numeric.md. Divergência ali é medida, não inferida:
+ cada uma vira achado da regra 10, na linha que produz o valor.
+ 3. Se /tmp/check/design/summary.md existe, o design mudou e o código
+ correspondente está defasado até prova em contrário (§13). Para
+ cada token, componente ou composição que mudou, confira o arquivo
+ correspondente, mesmo que ele não esteja no diff.
+ 4. Regras 7 e 9, que exigem comparar com o design.
+ 5. human.md, se couber.
+
+ PARE CEDO: sem ocorrência no scan, sem divergência no numeric e sem
+ design/summary.md, confira só a regra 9 nas páginas do diff e termine.
+
+ ## Evidência, em /tmp/check — cada uma autoridade sobre uma coisa
+
+ - design/summary.md, design/screens.md: o que mudou no design
+ (tokens, componentes, composição; telas por render).
+ - audit/components.html: geometria e tipografia já em px, por
+ data-pencil-name. NUNCA use Read nele (~108KB): grep -A3
+ 'data-pencil-name="Chip"'. Só tema claro. Não copie código dali.
+ - audit/components.json: qual token cada propriedade usa.
+ - audit/tokens.json: o valor de cada token em light e dark.
+ - audit/screens.json: a composição de cada tela. NUNCA use Read nele
+ (passa de 2000 linhas e trunca): jq '.[] | select(.node == "Sign In")'
+ /tmp/check/audit/screens.json. Liste com jq -r '.[].node'.
+ - design/screen-routes.json, no repositório: tela -> page.tsx -> URL.
+
+ ## Escopo
+
+ git diff --name-only origin/${{ github.base_ref }}...HEAD
+
+ - Mudou código (app/, components/): audite os arquivos alterados.
+ - Mudou o design: compare com o código correspondente, mesmo fora do diff.
+ - Os dois: faça as duas coisas.
+
+ Não reporte estilo, nomes ou arquitetura fora das dez regras.
+ claude_args: |
+ --max-turns 60
+ --allowedTools "Read,Grep,Glob,Write,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(jq:*),Bash(grep:*),Bash(diff:*),Bash(comm:*),Bash(sed:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(ls:*)"
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() && steps.gate.outputs.publish == 'true' }}
+ with:
+ name: design-check-${{ github.event.pull_request.number }}
+ path: |
+ ${{ env.WORK }}/design/artifact
+ ${{ env.WORK }}/*.md
+ ${{ env.WORK }}/findings.json
+ if-no-files-found: warn
+
+ # PR de fork: o token é só leitura, então o relatório vai para o job
+ # summary em vez de comentário.
+ - name: Publicar
+ id: publish
+ if: ${{ !cancelled() && steps.gate.outputs.publish == 'true' }}
+ continue-on-error: true
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ CHECK_MODE: ${{ github.event.pull_request.head.repo.full_name != github.repository && 'summary' || 'pr' }}
+ CHECK_DRAFT: ${{ github.event.pull_request.draft }}
+ AGENT_OUTCOME: ${{ steps.agent.outcome }}
+ run: node scripts/publish.mjs "$WORK"
+
+ # Vermelho se houver achado, se a auditoria não terminou ou se algum passo
+ # quebrou. "Não auditou" nunca pode parecer "passou" (§14).
+ - name: Veredito
+ if: ${{ !cancelled() }}
+ env:
+ OUTCOMES: >-
+ design=${{ steps.design.outcome }}
+ evidence=${{ steps.evidence.outcome }}
+ numeric=${{ steps.numeric.outcome }}
+ scan=${{ steps.scan.outcome }}
+ agent=${{ steps.agent.outcome }}
+ publish=${{ steps.publish.outcome }}
+ PUBLISHED: ${{ steps.gate.outputs.publish }}
+ run: |
+ fail=0
+ for kv in $OUTCOMES; do
+ if [ "${kv#*=}" = failure ]; then echo "::error::o passo '${kv%%=*}' falhou"; fail=1; fi
+ done
+ if [ "$PUBLISHED" = true ]; then
+ if [ ! -f "$WORK/verdict.json" ]; then
+ echo "::error::a publicação não gravou verdict.json"; fail=1
+ else
+ cat "$WORK/verdict.json"
+ if [ "$(jq .findings "$WORK/verdict.json")" -gt 0 ]; then
+ echo "::error::$(jq .findings "$WORK/verdict.json") achado(s) — veja o comentário do PR"; fail=1
+ fi
+ if [ "$(jq .invalid "$WORK/verdict.json")" -gt 0 ]; then echo "::error::achados malformados do agente"; fail=1; fi
+ if [ "$(jq .agentMissing "$WORK/verdict.json")" = true ]; then echo "::error::o agente não gravou findings.json"; fail=1; fi
+ if [ "$(jq .postErrors "$WORK/verdict.json")" -gt 0 ]; then echo "::error::a API recusou comentários inline"; fail=1; fi
+ fi
+ fi
+ exit $fail
diff --git a/.github/workflows/design-diff.yml b/.github/workflows/design-diff.yml
deleted file mode 100644
index 81f7dfd..0000000
--- a/.github/workflows/design-diff.yml
+++ /dev/null
@@ -1,174 +0,0 @@
-name: Design diff
-
-# Não existe `pen diff`. A comparação é montada aqui: worktree da base, render
-# dos dois lados via `pen interactive` headless, e três camadas de diff textual.
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, ready_for_review]
- paths:
- # só a fonte do design — mexer no DESIGN-SYSTEM.md não justifica render
- - "design/pendev/**"
- - "scripts/**"
- - ".github/workflows/design-diff.yml"
-
-permissions:
- contents: read
- pull-requests: write
- id-token: write
-
-concurrency:
- group: design-diff-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-env:
- PEN_FILE: design/pendev/youtube-channel.pen
-
-jobs:
- diff:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- # >= 0.3.5 obrigatório: em 0.3.2 um .pen com fills de imagem relativos
- # carrega VAZIO sem erro fatal. scripts/pen-export.sh checa e falha.
- - name: Instalar pen.dev CLI
- run: npm install -g @pen.dev/cli && pen version
-
- # O .pen referencia ./assets/* relativamente, então a versão da base
- # precisa da árvore inteira — um worktree, não um `git show` solto.
- - name: Worktree da base
- run: git worktree add --detach ../base "origin/${{ github.base_ref }}"
-
- - name: Renderizar os dois lados
- env:
- PEN_CLI_KEY: ${{ secrets.PEN_CLI_KEY }}
- run: |
- ./scripts/pen-export.sh "../base/$PEN_FILE" /tmp/render/base 1
- ./scripts/pen-export.sh "$PEN_FILE" /tmp/render/head 1
- mkdir -p /tmp/render/out
- for f in /tmp/render/base/*.png; do cp "$f" "/tmp/render/out/base-$(basename "$f")"; done
- for f in /tmp/render/head/*.png; do cp "$f" "/tmp/render/out/head-$(basename "$f")"; done
-
- # Três camadas, da mais específica para a mais bruta. O diff do arquivo
- # inteiro seria ruído de coordenada, por isso nenhuma delas é ele.
- #
- # tokens -> cor/escala trocada, token add/removido
- # inventário -> componente add/removido/renomeado
- # estrutura -> mudança DENTRO de um componente (padding do Chip etc),
- # que não aparece nas duas anteriores
- #
- # Composição das telas não entra: instanciada dá milhares de linhas.
- # Mudança de tela se vê no PNG.
- - name: Diff de tokens, inventário e estrutura
- run: |
- strip='walk(if type == "object" then del(.id, .x, .y) else . end)'
- for side in base head; do
- src="$PEN_FILE"; [ "$side" = base ] && src="../base/$PEN_FILE"
- jq -S '.variables // {}' "$src" > "/tmp/render/$side.tokens.json"
- jq -r '[.. | objects | select(.reusable == true) | .name] | sort | .[]' \
- "$src" > "/tmp/render/$side.inventory.txt"
- jq -S "[.. | objects | select(.reusable == true)] | sort_by(.name) | map($strip)" \
- "$src" > "/tmp/render/$side.shape.json"
- done
-
- # Comentário de PR estoura em 65536 chars; cada seção tem teto próprio.
- emit() {
- echo "### $1"; echo
- if diff -q "/tmp/render/base.$2" "/tmp/render/head.$2" >/dev/null; then
- echo '_sem mudança_'
- else
- d=$(diff -u "/tmp/render/base.$2" "/tmp/render/head.$2" | tail -n +3)
- n=$(printf '%s\n' "$d" | wc -l)
- echo '```diff'
- printf '%s\n' "$d" | head -120
- echo '```'
- [ "$n" -gt 120 ] && echo "_+$((n - 120)) linhas omitidas — veja o artefato._"
- fi
- echo
- }
- {
- emit Tokens tokens.json
- emit "Inventário de componentes" inventory.txt
- emit "Estrutura dos componentes" shape.json
- } > /tmp/render/summary.md
- cp /tmp/render/*.json /tmp/render/*.txt /tmp/render/out/ 2>/dev/null || true
- cat /tmp/render/summary.md
-
- - uses: actions/upload-artifact@v4
- with:
- name: design-render-${{ github.event.pull_request.number }}
- path: /tmp/render/out/*
- if-no-files-found: error
-
- # O diff acima é exato e ilegível: quem revisa precisa conhecer o sistema
- # de tokens para saber que $space-3 -> $space-4 é "o chip ficou 4px mais
- # largo". O agente escreve a leitura; o diff cru fica dobrado embaixo.
- #
- # Ele NÃO posta: escreve um arquivo, e o passo seguinte publica. Assim a
- # publicação continua determinística e o comentário sai mesmo se o agente
- # falhar — só que sem a parte humana.
- - uses: anthropics/claude-code-action@v1
- continue-on-error: true
- with:
- anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- prompt: |
- Escreva a leitura humana de uma mudança de design, para quem vai
- revisar este PR.
-
- Entrada:
- - /tmp/render/summary.md — o diff de tokens, inventário e estrutura
- - /tmp/render/head.tokens.json — os tokens depois da mudança
- - /tmp/render/base.tokens.json — antes
-
- As regras de tradução estão em design/DESIGN-SYSTEM.md, já carregado
- via CLAUDE.md.
-
- Escreva no MÁXIMO 12 linhas, em português, e grave em
- /tmp/render/human.md. Não poste nada; não use ferramenta de
- comentário.
-
- O que a leitura precisa ter:
- - o que mudou em PIXELS, não em nome de token. "$space-3 -> $space-4"
- não diz nada; "o padding lateral do Chip vai de 12px para 16px" diz.
- - o que isso exige do código: nomeie o arquivo e a classe que
- precisam mudar (ex: components/chip.tsx, px-3 -> px-4), e o
- app/globals.css quando for token.
- - o que passa despercebido: se o valor novo cair numa das armadilhas
- do doc (radius deslocado, $space-14, text-lg), diga.
-
- Se o resumo indicar que nada mudou, escreva uma linha só dizendo isso.
- claude_args: |
- --max-turns 15
- --allowedTools "Read,Grep,Glob,Write,Bash(jq:*),Bash(sed:*),Bash(head:*)"
-
- - name: Comentar no PR
- if: always()
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- {
- echo "## Design vs \`${{ github.base_ref }}\`"
- echo
- if [ -s /tmp/render/human.md ]; then
- cat /tmp/render/human.md
- echo
- echo "Diff exato (tokens, inventário, estrutura)
"
- echo
- cat /tmp/render/summary.md
- echo " "
- else
- echo "_A leitura automática falhou; segue o diff cru._"
- echo
- cat /tmp/render/summary.md
- fi
- echo
- echo "Renders antes/depois: [artefato do run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts)"
- } > /tmp/render/comment.md
- gh pr comment "${{ github.event.pull_request.number }}" --body-file /tmp/render/comment.md
diff --git a/.github/workflows/design-drift.yml b/.github/workflows/design-drift.yml
deleted file mode 100644
index ae37b79..0000000
--- a/.github/workflows/design-drift.yml
+++ /dev/null
@@ -1,222 +0,0 @@
-name: Design drift
-
-# Audita se o código traduziu o design corretamente. As regras não vão no
-# prompt: o CLAUDE.md faz @design/DESIGN-SYSTEM.md, então a action carrega o
-# guia inteiro sozinha. O prompt só nomeia o que auditar e onde reportar.
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, ready_for_review]
- paths:
- - "app/**"
- - "components/**"
- - "design/**"
- - "scripts/**"
- - ".github/workflows/design-drift.yml"
-
-permissions:
- contents: read
- pull-requests: write
- issues: read
- id-token: write
-
-concurrency:
- group: design-drift-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-env:
- PEN_FILE: design/pendev/youtube-channel.pen
- APP_URL: http://localhost:3000/@FullCycle/videos
-
-jobs:
- audit:
- if: github.event.pull_request.draft == false
- runs-on: ubuntu-latest
- timeout-minutes: 20
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - name: Instalar pen.dev CLI
- run: npm install -g @pen.dev/cli && pen version
-
- # Três evidências, todas textuais. Nenhum PNG: render do design sem render
- # da app é comparação de um lado só. E a tool `browser` do pen, que
- # fecharia o loop carregando a app, exige o app desktop e não roda aqui.
- #
- # components.html geometria/tipografia RESOLVIDA em px (só tema claro)
- # components.json qual token o design usa (nome do token)
- # tokens.json o que cada token vale em light e dark
- # screens.json composição das telas: quais componentes, em que ordem
- #
- # As telas não são `reusable`, então ficariam fora das três primeiras —
- # sem screens.json o audit cobre componente e não cobre página.
- #
- # O HTML existe para o agente não ter que resolver $radius-sm -> 8px
- # sozinho — errar essa resolução é literalmente a regra 3 do audit.
- - name: Extrair design
- env:
- PEN_CLI_KEY: ${{ secrets.PEN_CLI_KEY }}
- run: |
- mkdir -p /tmp/design
- jq -S '{themes, variables}' "$PEN_FILE" > /tmp/design/tokens.json
- jq -S '[.. | objects | select(.reusable == true)] | sort_by(.name)
- | map(walk(if type == "object" then del(.id, .x, .y) else . end))' \
- "$PEN_FILE" > /tmp/design/components.json
- NODES=$(jq -r '[.. | objects | select(.reusable == true) | .name] | join(";")' "$PEN_FILE")
- PEN_NODES="$NODES" ./scripts/pen-export.sh \
- "$PEN_FILE" /tmp/design/components.html 1 html-tailwind
- ./scripts/pen-outline.py "$PEN_FILE" > /tmp/design/screens.json
- ls -la /tmp/design
-
- # A comparação numérica: sobe a app, lê getComputedStyle de cada
- # [data-component] e confronta com o design renderizado. `rounded-lg` no
- # código e `rounded-[8px]` no design são strings diferentes e o mesmo
- # valor — o navegador normaliza os dois dialetos, então isto vira
- # aritmética e não julgamento. Não roda se ainda não houver rota.
- - name: Comparação numérica com a app
- id: numeric
- continue-on-error: true
- shell: bash
- run: |
- set -x
- routes=$(find app -name 'page.tsx' -not -path 'app/page.tsx' 2>/dev/null || true)
- if [ -z "$routes" ]; then
- echo "sem rota de tela ainda — comparação numérica pulada" > /tmp/design/numeric.md
- cat /tmp/design/numeric.md
- exit 0
- fi
-
- # `npm ci` exige lock em sincronia, e npm de versões diferentes
- # resolvem dependências nativas opcionais (@emnapi/*) de formas
- # diferentes. Este step é verificação, não build de release: se o
- # lock não bater, instalar mesmo assim é melhor que não medir nada.
- npm ci || npm install --no-audit --no-fund
- npx playwright install chromium
- npm run build
- npm start &
-
- for _ in $(seq 1 40); do
- curl -sf -o /dev/null "$APP_URL" && break || sleep 2
- done
- curl -sf -o /dev/null "$APP_URL" || { echo "a app não subiu em $APP_URL" > /tmp/design/numeric.md; cat /tmp/design/numeric.md; exit 1; }
-
- node scripts/compare-styles.mjs /tmp/design/components.html "$APP_URL" \
- > /tmp/design/numeric.md 2>&1 || true
- cat /tmp/design/numeric.md
-
- # Seis das nove regras são regex sobre o diff. Rodá-las aqui é
- # determinístico e grátis; o agente entra só para verificar e julgar.
- - name: Pré-análise mecânica
- env:
- PEN_FILE: design/pendev/youtube-channel.pen
- run: ./scripts/drift-scan.sh "origin/${{ github.base_ref }}" | tee /tmp/design/scan.txt
-
- - uses: anthropics/claude-code-action@v1
- with:
- anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- prompt: |
- Audite drift entre o design e o código neste PR.
-
- As regras estão em design/DESIGN-SYSTEM.md, já carregado via CLAUDE.md.
-
- SIGA ESTA ORDEM. Ela existe para você não gastar turnos:
-
- 1. Leia /tmp/design/scan.txt PRIMEIRO. A varredura mecânica já rodou
- sobre o diff e listou as ocorrências das regras 1, 2, 4, 5, 6, 8,
- os radius da 3, e os data-component sem frame no .pen (metade da
- 7). Se uma seção não aparece lá, essa regra passou: NÃO a
- reinvestigue, e não abra arquivo para reconferir o que ela cobre.
- 2. Para cada ocorrência listada, abra só aquele arquivo e confirme
- se é violação de verdade ou uso correto.
- 3. Leia /tmp/design/numeric.md. As divergências ali são medidas,
- não inferidas — vire cada uma em achado da regra 10.
- 4. Só então cheque as regras 7 e 9, que exigem comparar com o design.
-
- PARE CEDO. Se scan.txt não listar nenhuma ocorrência E numeric.md
- disser "nenhuma divergência numérica", então as regras 1 a 8 e a 10
- já passaram por medição. Cheque só a 9 e termine. Não vá reler
- componentes para "confirmar" o que já foi medido — medição não
- precisa de segunda opinião.
-
- NUNCA use Read em /tmp/design/components.html: são 107KB e vai
- estourar seu contexto. Use grep com data-pencil-name="".
-
- Evidência disponível, cada uma autoridade sobre uma coisa:
-
- - /tmp/design/components.html — cada componente com geometria e
- tipografia JÁ RESOLVIDAS em px, marcado com data-pencil-name.
- Grepe por data-pencil-name="Chip" para achar um componente.
- Use para conferir NÚMEROS: 32px de altura, 8px de radius, 14/20 de
- texto. Só tem tema claro. NUNCA copie código daqui: a saída é
- achatada (hex cru, px absoluto) e viola as regras 1, 2 e 9.
- - /tmp/design/components.json — qual TOKEN o design usa em cada
- propriedade ($radius-sm, $surface-chip). Use quando a pergunta for
- "qual token deveria estar aqui".
- - /tmp/design/tokens.json — o que cada token vale em light E dark.
- Autoridade sobre cor e tema; o CSS do repo é transcrição e pode
- estar errado.
- - /tmp/design/numeric.md — a comparação numérica JÁ FEITA entre o
- design renderizado e a app rodando, por [data-component]. Se ela
- listar divergências, elas são fato, não hipótese: reporte cada uma
- como regra 10, apontando o arquivo do componente. Se disser
- "nenhuma divergência numérica", não reinvestigue geometria de
- componente — foi medida.
- - /tmp/design/screens.json — a COMPOSIÇÃO de cada tela: quais
- componentes, em que ordem, dentro de que container, com que gap e
- padding. É contra isto que os arquivos em app/ devem bater.
- Conteúdo instanciado (títulos, thumbnails) foi podado de
- propósito: aquilo é fixture, não design.
-
- Escopo — decida pelo que mudou:
- git diff --name-only origin/${{ github.base_ref }}...HEAD
-
- - Mudou código (app/, components/): audite os arquivos alterados.
- - Mudou SÓ o design (design/pendev/): o código não mudou, mas pode
- ter ficado defasado. Compare o que mudou no .pen contra os
- componentes React correspondentes, mesmo que eles não estejam no
- diff. Este é o caso que mais escapa em review.
- - Mudaram os dois: faça as duas coisas.
-
- Procure exatamente estas dez falhas — todas compilam e parecem
- certas, por isso passam em review:
-
- 1. Cor crua em className (`#`, `rgb(`, `hsl(`) — quebra o tema escuro
- 2. Qualquer variante `dark:` — significa token semântico faltando
- 3. Radius traduzido pelo nome em vez do valor. Os nomes do .pen estão
- deslocados um degrau: $radius-sm é 8px e vira rounded-lg, não
- rounded-sm. Confira contra a tabela do §6.
- 4. `p-14` onde o design pede $space-14 (58px, não os 56px de p-14)
- 5. `text-lg`/`text-sm` usados achando que batem com o .pen
- (font-size-lg é 16px; text-lg do Tailwind é 18px)
- 6. Altura fixa em thumbnail (`h-[201px]`) em vez de aspect-video
- 7. Token ou componente que não existe em tokens.json nem no .pen.
- Exceção única: estados de interação e foco (§9).
- 8. Prop `className` exposta em componente (§7)
- 9. Página que não bate com screens.json: componente faltando, ordem
- trocada, container com gap ou padding diferente. Atenção ao
- Header Region, que usa $space-14 — 58px, não os 56px de p-14.
- 10. Componente cujos valores DIVERGEM do design. Esta é a definição
- de drift e a mais importante da lista: o componente existe, o
- nome está certo, e uma propriedade não bate — padding, gap,
- altura, cor, tipografia. Confira contra components.html (px já
- resolvido) e components.json (o token). Vale mesmo quando o
- .tsx não está no diff: se o scan disser que o design mudou, o
- código correspondente está defasado até prova em contrário.
-
- Para cada achado, poste comentário inline na linha exata, com a regra
- violada e a correção, citando a seção do DESIGN-SYSTEM.md.
- Comentário inline só cola em linha que está no diff: se o arquivo
- defasado não mudou neste PR, comente na linha do .pen que causou a
- divergência, nomeando o arquivo e a linha do código.
- Se não houver achado, não poste nada.
-
- Não reporte estilo, nomes ou arquitetura fora dessas dez.
- claude_args: |
- --max-turns 60
- --allowedTools "mcp__github_inline_comment__create_inline_comment,Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(jq:*),Bash(diff:*),Bash(comm:*),Bash(sed:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(ls:*)"
diff --git a/design/DESIGN-SYSTEM.md b/design/DESIGN-SYSTEM.md
index 924742a..9c6625e 100644
--- a/design/DESIGN-SYSTEM.md
+++ b/design/DESIGN-SYSTEM.md
@@ -252,11 +252,17 @@ dessa linha — player, barra de ações e comentários são componentes novos.
### Rotas
-```
-app/[handle]/page.tsx ← Channel — Home (desenhado)
-app/[handle]/videos/page.tsx ← Channel — Videos (desenhado)
+O mapa tela → página → URL é `design/screen-routes.json`. É por ele que o CI
+mede a app (§14), então rota nova entra lá:
+
+```json
+"Channel — Videos": { "page": "app/[handle]/videos/page.tsx", "url": "/@FullCycle/videos" }
```
+Tela com entrada e sem `page.tsx` aparece como "não implementada"; tela do
+`.pen` sem entrada, como "sem entrada". Nenhum dos dois é erro. Erro é o
+contrário: entrada para uma tela que o `.pen` não tem mais.
+
`[handle]` captura o segmento literal `@FullCycle`. **Nunca crie uma pasta
`@handle`** — no App Router `@folder` é slot de parallel route e não aparece na
URL. `params` é Promise: `await`. Tipos de props são globais e gerados
@@ -293,18 +299,33 @@ Ao terminar uma tela:
1. `npm run dev`
2. Emular 1440×900 com `prefers-color-scheme: light` → screenshot
3. Emular `prefers-color-scheme: dark` → screenshot
-4. Checklist:
-
-```
-☐ grep -rE 'dark:|#[0-9a-fA-F]{3,}|rgb\(|hsl\(' components/ app/ → vazio
-☐ todo [Npx] no diff tem origem no .pen
-☐ nenhum rounded-sm/md/lg escrito sem conferir a tabela do §6
-☐ nenhum h-[...] em thumbnail (§8)
-☐ nenhum token ou componente novo (§10)
-```
+4. Conferir as dez regras abaixo — `scripts/drift-scan.sh origin/main` roda
+ localmente a mesma varredura do CI
O tema escuro é obrigatório: é onde um hex esquecido aparece.
+### As dez regras
+
+Esta é a lista canônica. O CI (§14) audita exatamente estas, **com estes
+números**: o número entra na chave que evita repostar o mesmo achado a cada
+push, então renumerar faz todo achado antigo voltar como novo. Regra nova entra
+no fim.
+
+Todas compilam e parecem certas — é por isso que passam em review.
+
+| # | falha | regra de origem | quem pega no CI |
+|---|---|---|---|
+| 1 | cor crua em `className` (`#`, `rgb(`, `hsl(`) — quebra o tema escuro | §2.2, §3 | varredura + agente |
+| 2 | variante `dark:` — faltou token semântico | §2.1 | varredura + agente |
+| 3 | radius traduzido pelo nome, não pelo valor (`$radius-sm` é 8px → `rounded-lg`) | §6 | varredura + agente |
+| 4 | `p-14` onde o design pede `$space-14` (58px, não 56px) | §4 | varredura + agente |
+| 5 | `text-lg`, `text-sm`… usados achando que batem com o `.pen` | §5 | varredura + agente |
+| 6 | altura fixa em thumbnail (`h-[201px]`) em vez de `aspect-video` | §8 | varredura + agente |
+| 7 | token ou componente que não existe no `.pen` (exceção: §9) | §2.3, §2.4, §10 | varredura (`data-component`) + agente |
+| 8 | prop `className` exposta em componente | §7 | varredura + agente |
+| 9 | página que não bate com a composição da tela: componente faltando, ordem trocada, gap ou padding de container diferente | §10 | agente |
+| 10 | valor que diverge do design: o componente existe e uma propriedade não bate — padding, gap, altura, cor, tipografia, px cru sem origem no `.pen` | §2.5, §13 | medição + agente |
+
## 13. Quando o design mudar
O `.pen` muda primeiro. Depois:
@@ -320,52 +341,141 @@ errado.
## 14. CI
-`scripts/pen-export.sh` renderiza telas nomeadas do `.pen`, headless. Ele
-checa a versão do CLI e falha se alguma tela não resolver — no design, tela
-renomeada tem que quebrar o CI, não sumir do relatório.
-
-Dois workflows em `.github/workflows/`:
-
-- **`design-diff.yml`** — dispara quando `design/` muda. Não existe `pen diff`,
- então a comparação é montada sobre um **worktree** da base (não um `git show`
- para `/tmp`: o `.pen` resolve `./assets/*` relativo à própria árvore, e o
- render sairia sem imagem nenhuma — silenciosamente). São três camadas:
-
- | camada | pega |
- |---|---|
- | `variables` | cor ou escala trocada, token adicionado/removido |
- | inventário de `reusable` | componente adicionado, removido, renomeado |
- | estrutura de cada `reusable` (sem `id`/`x`/`y`) | mudança **dentro** de um componente |
-
- A terceira existe porque as duas primeiras têm um ponto cego: trocar o padding
- do Chip de `$space-3` para `$space-4` não mexe em token nem em inventário.
- Composição de tela não entra em nenhuma delas — instanciada dá milhares de
- linhas; mudança de tela se vê no PNG, que vai como artefato.
-- **`design-drift.yml`** — dispara quando `app/`, `components/` ou `design/`
- mudam. Roda `claude-code-action` em automation mode com quatro evidências,
- cada uma autoridade sobre uma coisa:
-
- | arquivo | autoridade sobre |
- |---|---|
- | `components.html` | geometria e tipografia **resolvidas em px** (só tema claro) |
- | `components.json` | qual **token** o design usa em cada propriedade |
- | `tokens.json` | o que cada token vale em **light e dark** |
- | `screens.json` | **composição** das telas: quais componentes, em que ordem |
-
- O HTML existe para o agente não ter que resolver `$radius-sm` → 8px sozinho —
- errar essa resolução é literalmente a regra 3 que ele deveria estar auditando.
-
- O `screens.json` existe porque as três primeiras só olham `reusable`, e tela
- não é reusable: sem ele o audit cobre componente e deixa página descoberta.
- `scripts/pen-outline.py` o produz podando o conteúdo instanciado — títulos e
- thumbnails são fixture, não design —, o que derruba as duas telas de ~2000
- para ~460 linhas.
-
- Audita as nove falhas do §12 e posta inline. As regras não vão no prompt: o
- `CLAUDE.md` faz `@design/DESIGN-SYSTEM.md`, então este arquivo chega inteiro.
+Um workflow, `.github/workflows/design-check.yml`, responde duas perguntas: **o
+que mudou no design** e **se o código acompanha o design**. Um job, um agente,
+um comentário. Antes eram dois workflows com dois agentes, e os dois diziam a
+quem revisa o que mudar no código — um deduzindo do diff do design, o outro
+conferindo o código —, às vezes discordando. A lógica mora em `scripts/`: o
+YAML só encadeia, e cada passo roda local (fim desta seção). O fluxo desenhado
+está em `design/diagrams/design-check.excalidraw`.
+
+### O que roda
+
+`scripts/check-gate.sh` decide pelo que o PR mudou:
+
+| o PR mexe em | diff do design | auditoria |
+|---|---|---|
+| `design/pendev/**`, `scripts/**`, o próprio workflow | sim | sim |
+| `app/**`, `components/**`, este arquivo, `design/screen-routes.json` | não | sim |
+
+- **Rascunho** roda só o diff do design, com render: quem itera no `.pen` vê o
+ antes/depois sem pagar build e agente a cada push. Medição e agente rodam
+ quando o PR sai de rascunho.
+- **Fork** não recebe secret nem token de escrita. Roda o que não precisa deles
+ — diff textual do design, sem render, e a varredura — e o relatório vai para
+ o job summary. `pull_request_target` está fora de questão: o job faz
+ `npm install` e build do código do PR, e um `postinstall` levaria as chaves.
+
+### Diff do design — `scripts/design-diff.sh`
+
+Não existe `pen diff`. A base é um **worktree** (não um `git show` para `/tmp`:
+o `.pen` resolve `./assets/*` relativo à própria árvore, e o render sairia sem
+imagem nenhuma — silenciosamente). São quatro camadas:
+
+| camada | pega |
+|---|---|
+| tokens (`{themes, variables}`) | cor, escala ou tema trocado; token adicionado/removido |
+| inventário de `reusable` | componente adicionado, removido, renomeado |
+| estrutura de cada `reusable` (sem `id`/`x`/`y`) | mudança **dentro** de um componente |
+| composição das telas que mudaram (`pen-outline.py`) | componente que entrou, saiu ou trocou de lugar numa tela |
+
+As três primeiras saem de `scripts/pen-digest.sh`, o único lugar onde esses
+filtros existem — diff e auditoria leem dele. A terceira existe porque as duas
+primeiras têm um ponto cego: trocar o padding do Chip de `$space-3` para
+`$space-4` não mexe em token nem em inventário.
+
+A quarta depende do render. `scripts/pen-screens.sh` é a definição de **tela**:
+frame de topo que não é `reusable` nem peça do board (`Sheet /`, `Label /`,
+`Foundations`). A lista sai do próprio `.pen`, de cada lado, nunca de uma
+constante — uma lista fixa deixa tela nova fora do relatório, em silêncio.
+Cada tela sai como adicionada, removida ou mudou (bytes do PNG diferentes; o
+render é determinístico). Identidade é o nome: renomear é removida +
+adicionada. Só as que mudaram ganham diff de composição — a árvore instanciada
+de todas daria milhares de linhas — e só os PNGs delas vão para o artefato.
+Render diferente com composição igual quer dizer que a causa está num
+componente ou token, nas três primeiras camadas.
+
+`scripts/pen-export.sh` renderiza nós nomeados, headless; sem `PEN_NODES`,
+todas as telas. Ele checa a versão do CLI e falha se algum nome pedido não
+resolver. O `Export` grava `.png`, não o nome — quem precisa do nome
+renomeia.
+
+### Auditoria
+
+O agente recebe evidência em que cada arquivo é autoridade sobre uma coisa:
+
+| arquivo | autoridade sobre |
+|---|---|
+| `components.html` | geometria e tipografia **resolvidas em px** (só tema claro) |
+| `components.json` | qual **token** o design usa em cada propriedade |
+| `tokens.json` | o que cada token vale em **light e dark** |
+| `screens.json` | **composição** das telas: quais componentes, em que ordem |
+| `numeric.md` | a app rodando, **medida** contra o design |
+| `scan.md` | ocorrências mecânicas das regras 1 a 8 |
+
+O HTML existe para o agente não ter que resolver `$radius-sm` → 8px sozinho —
+errar essa resolução é literalmente a regra 3 que ele deveria estar auditando.
+
+O `screens.json` existe porque os JSONs só olham `reusable`, e tela não é
+reusable: sem ele a auditoria cobre componente e deixa página descoberta.
+`scripts/pen-outline.py` o produz podando o conteúdo instanciado — títulos e
+thumbnails são fixture, não design —, o que derruba as duas telas de canal de
+~1700 para ~460 linhas. Tela montada à mão poda menos, então o agente pega uma
+tela por vez com `jq`, nunca o arquivo inteiro.
+
+- **Medição** — `scripts/measure-app.sh` sobe a app e `compare-styles.mjs`
+ compara o `getComputedStyle` de cada `[data-component]` com o export do
+ design, em cada página de `design/screen-routes.json` (§10). Divergência vira
+ achado da regra 10; não conseguir medir (CDN, página que não responde, mapa
+ desatualizado) é falha.
+- **Varredura** — `scripts/drift-scan.sh`, regex das regras 1 a 8 sobre os
+ arquivos do diff. Ocorrência, não achado: `rounded-lg` pode estar certo.
+- **Agente** — `claude-code-action` verifica as ocorrências, transforma
+ medição em achado, confere as regras 7 e 9 e, se o design mudou, procura o
+ código que ficou para trás mesmo fora do diff. As regras não vão no prompt:
+ o `CLAUDE.md` faz `@design/DESIGN-SYSTEM.md`, então este arquivo chega
+ inteiro, e os números são os do §12. Ele **não posta**: escreve `human.md` (o
+ que mudou no design, em px — nunca o que mudar no código) e `findings.json`
+ (achados verificados, com arquivo, linha e regra). Por não postar, roda com o
+ `github_token` do job: sem ele, a action troca OIDC pelo token do app do
+ Claude, troca que exige o workflow idêntico ao do branch padrão — e em PR que
+ mexe no workflow ela sai com "success" sem rodar nada.
+
+### Publicação — `scripts/publish.mjs`
+
+- Um comentário-resumo, **editado no lugar** a cada push.
+- Achado em linha do diff vira comentário inline, numa review. Achado fora do
+ diff — código que não mudou e ficou para trás — vai numa lista no resumo: a
+ API recusa inline fora do diff.
+- Inline só para achado **novo**. A chave é arquivo + regra + texto da linha:
+ sobrevive a linha inserida acima e ao agente escrever o mesmo achado com
+ outras palavras. Achado corrigido fica *outdated* sozinho.
+- Resumo e achados são reconhecidos por marcador oculto no texto, não pelo
+ autor: o autor depende do token, e comentário *outdated* perde o número da
+ linha.
+
+O check fica **vermelho** se houver achado, se o agente falhar ou se algum
+passo quebrar — e o resultado mecânico é publicado em todos os casos. "Não
+auditou" nunca pode parecer "passou", e o §1 já decidiu que a transcrição é
+parte da tarefa: PR que muda o design sem acompanhar o código está incompleto.
Secrets: `PEN_CLI_KEY` e `ANTHROPIC_API_KEY`.
+### Rodar local
+
+```bash
+git worktree add --detach ../base origin/main
+scripts/design-diff.sh ../base/design/pendev/youtube-channel.pen \
+ design/pendev/youtube-channel.pen /tmp/check/design
+scripts/drift-scan.sh origin/main
+APP_ORIGIN=http://localhost:3000 SKIP_BUILD=1 \
+ scripts/measure-app.sh /tmp/check/audit/components.html /tmp/check/audit/screens.tsv
+```
+
+A evidência de `measure-app.sh` sai como no passo "Evidência" do workflow. Não
+a gere dentro do repositório: o Tailwind varre o projeto atrás de classes, acha
+as do export e quebra o build.
+
### A armadilha do `html-tailwind`
`Export(ids, "html-tailwind", ...)` produz um snapshot achatado: hex cru, zero
@@ -378,8 +488,8 @@ Secrets: `PEN_CLI_KEY` e `ANTHROPIC_API_KEY`.
```
**Serve para conferir números, nunca para copiar código** — colar dali viola as
-regras 1, 2 e 9 de uma vez. É por isso que o CI o entrega ao auditor junto com
-os dois JSONs, e não sozinho: o HTML diz *quanto é*, o JSON diz *qual token*.
+regras 1 e 10 do §12 de uma vez. É por isso que o CI o entrega ao agente junto
+com os JSONs, e não sozinho: o HTML diz *quanto é*, o JSON diz *qual token*.
### O que o CLI não faz em CI
diff --git a/design/diagrams/design-check.excalidraw b/design/diagrams/design-check.excalidraw
new file mode 100644
index 0000000..3cffcc8
--- /dev/null
+++ b/design/diagrams/design-check.excalidraw
@@ -0,0 +1,1673 @@
+{
+ "type": "excalidraw",
+ "version": 2,
+ "source": "https://excalidraw.com",
+ "elements": [
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 229412539,
+ "version": 1,
+ "versionNonce": 1322589171,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t1",
+ "type": "text",
+ "x": 60,
+ "y": 30,
+ "width": 917.2800000000001,
+ "height": 35.0,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "design-check — “o que mudou no design, e o código acompanha?”",
+ "originalText": "design-check — “o que mudou no design, e o código acompanha?”",
+ "fontSize": 28,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1508790918,
+ "version": 1,
+ "versionNonce": 1621790937,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t2",
+ "type": "text",
+ "x": 60,
+ "y": 72,
+ "width": 1156.48,
+ "height": 20.0,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "Um workflow, um job, um agente, um comentário. Dispara em PR que mexe em design/pendev, app/, components/, scripts/ ou no próprio workflow.",
+ "originalText": "Um workflow, um job, um agente, um comentário. Dispara em PR que mexe em design/pendev, app/, components/, scripts/ ou no próprio workflow.",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1400249569,
+ "version": 1,
+ "versionNonce": 1131964184,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t4"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r3",
+ "type": "rectangle",
+ "x": 80,
+ "y": 125,
+ "width": 1080,
+ "height": 95,
+ "strokeColor": "#f08c00",
+ "backgroundColor": "#ffec99",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 2019391390,
+ "version": 1,
+ "versionNonce": 530289420,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t4",
+ "type": "text",
+ "x": 224.8,
+ "y": 142.5,
+ "width": 790.4,
+ "height": 60.0,
+ "strokeColor": "#f08c00",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "check-gate.sh — o que o PR mudou decide o que roda\ndesign mudou → diff + auditoria · só código/regras → só auditoria\nrascunho: só o diff do design · fork: sem secrets → diff sem render + varredura, no job summary",
+ "originalText": "check-gate.sh — o que o PR mudou decide o que roda\ndesign mudou → diff + auditoria · só código/regras → só auditoria\nrascunho: só o diff do design · fork: sem secrets → diff sem render + varredura, no job summary",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r3",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 582215513,
+ "version": 1,
+ "versionNonce": 1578683595,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t6"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r5",
+ "type": "rectangle",
+ "x": 80,
+ "y": 270,
+ "width": 520,
+ "height": 60,
+ "strokeColor": "#1971c2",
+ "backgroundColor": "#a5d8ff",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 549273731,
+ "version": 1,
+ "versionNonce": 624952100,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t6",
+ "type": "text",
+ "x": 171.51999999999998,
+ "y": 277.5,
+ "width": 336.96000000000004,
+ "height": 45.0,
+ "strokeColor": "#1971c2",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "DESIGN\n.pen da base (worktree) × .pen do PR",
+ "originalText": "DESIGN\n.pen da base (worktree) × .pen do PR",
+ "fontSize": 18,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r5",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1576380488,
+ "version": 1,
+ "versionNonce": 155962879,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t8"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r7",
+ "type": "rectangle",
+ "x": 640,
+ "y": 270,
+ "width": 520,
+ "height": 60,
+ "strokeColor": "#e03131",
+ "backgroundColor": "#ffc9c9",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1414010650,
+ "version": 1,
+ "versionNonce": 965812607,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t8",
+ "type": "text",
+ "x": 815.76,
+ "y": 277.5,
+ "width": 168.48000000000002,
+ "height": 45.0,
+ "strokeColor": "#e03131",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "CÓDIGO\napp/ · components/",
+ "originalText": "CÓDIGO\napp/ · components/",
+ "fontSize": 18,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r7",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 650661722,
+ "version": 1,
+ "versionNonce": 1002055270,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a9",
+ "type": "arrow",
+ "x": 350.0,
+ "y": 220,
+ "width": 10.0,
+ "height": 50,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -10.0,
+ 50
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1469379453,
+ "version": 1,
+ "versionNonce": 1874370927,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a10",
+ "type": "arrow",
+ "x": 890.0,
+ "y": 220,
+ "width": 10.0,
+ "height": 50,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 10.0,
+ 50
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 851850162,
+ "version": 1,
+ "versionNonce": 845699720,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t12"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r11",
+ "type": "rectangle",
+ "x": 80,
+ "y": 395,
+ "width": 240,
+ "height": 110,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "#b2f2bb",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1669704160,
+ "version": 1,
+ "versionNonce": 1951897724,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t12",
+ "type": "text",
+ "x": 125.89999999999999,
+ "y": 412.5,
+ "width": 148.20000000000002,
+ "height": 75.0,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "design-diff.sh\ntokens · inventário\ncomponentes\ntelas → composição",
+ "originalText": "design-diff.sh\ntokens · inventário\ncomponentes\ntelas → composição",
+ "fontSize": 15,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r11",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 254261142,
+ "version": 1,
+ "versionNonce": 565897243,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t14"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r13",
+ "type": "rectangle",
+ "x": 360,
+ "y": 395,
+ "width": 240,
+ "height": 110,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "#b2f2bb",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 479460332,
+ "version": 1,
+ "versionNonce": 1870058586,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t14",
+ "type": "text",
+ "x": 386.4,
+ "y": 412.5,
+ "width": 187.20000000000002,
+ "height": 75.0,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "evidência\npen-digest · pen-outline\npen-export →\ncomponents.html",
+ "originalText": "evidência\npen-digest · pen-outline\npen-export →\ncomponents.html",
+ "fontSize": 15,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r13",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1935877236,
+ "version": 1,
+ "versionNonce": 677870541,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t16"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r15",
+ "type": "rectangle",
+ "x": 640,
+ "y": 395,
+ "width": 240,
+ "height": 110,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "#b2f2bb",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 768869313,
+ "version": 1,
+ "versionNonce": 1734846376,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t16",
+ "type": "text",
+ "x": 670.3,
+ "y": 412.5,
+ "width": 179.4,
+ "height": 75.0,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "measure-app.sh\nbuild + start\ngetComputedStyle por\npágina do screen-routes",
+ "originalText": "measure-app.sh\nbuild + start\ngetComputedStyle por\npágina do screen-routes",
+ "fontSize": 15,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r15",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 559209745,
+ "version": 1,
+ "versionNonce": 774533166,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t18"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r17",
+ "type": "rectangle",
+ "x": 920,
+ "y": 395,
+ "width": 240,
+ "height": 110,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "#b2f2bb",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1727180910,
+ "version": 1,
+ "versionNonce": 1358773926,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t18",
+ "type": "text",
+ "x": 962.0,
+ "y": 412.5,
+ "width": 156.0,
+ "height": 75.0,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "drift-scan.sh\nregex das regras 1–8\nsó ocorrências,\nnão achados",
+ "originalText": "drift-scan.sh\nregex das regras 1–8\nsó ocorrências,\nnão achados",
+ "fontSize": 15,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r17",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1356487940,
+ "version": 1,
+ "versionNonce": 1108678607,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a19",
+ "type": "arrow",
+ "x": 210.0,
+ "y": 330,
+ "width": 10.0,
+ "height": 65,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -10.0,
+ 65
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 321485952,
+ "version": 1,
+ "versionNonce": 345853287,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a20",
+ "type": "arrow",
+ "x": 470.0,
+ "y": 330,
+ "width": 10.0,
+ "height": 65,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 10.0,
+ 65
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1183977893,
+ "version": 1,
+ "versionNonce": 1429004469,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a21",
+ "type": "arrow",
+ "x": 770.0,
+ "y": 330,
+ "width": 10.0,
+ "height": 65,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -10.0,
+ 65
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1425927116,
+ "version": 1,
+ "versionNonce": 591266699,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a22",
+ "type": "arrow",
+ "x": 1030.0,
+ "y": 330,
+ "width": 10.0,
+ "height": 65,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 10.0,
+ 65
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 354223677,
+ "version": 1,
+ "versionNonce": 22774703,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a23",
+ "type": "arrow",
+ "x": 600,
+ "y": 450,
+ "width": 40,
+ "height": 0,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 40,
+ 0
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1399511430,
+ "version": 1,
+ "versionNonce": 150188115,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t24",
+ "type": "text",
+ "x": 604,
+ "y": 429,
+ "width": 27.04,
+ "height": 16.25,
+ "strokeColor": "#2f9e44",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "html",
+ "originalText": "html",
+ "fontSize": 13,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 264197071,
+ "version": 1,
+ "versionNonce": 1277375818,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t26"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r25",
+ "type": "rectangle",
+ "x": 220,
+ "y": 580,
+ "width": 800,
+ "height": 100,
+ "strokeColor": "#6741d9",
+ "backgroundColor": "#d0bfff",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 723844213,
+ "version": 1,
+ "versionNonce": 59673153,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t26",
+ "type": "text",
+ "x": 253.91999999999996,
+ "y": 600.0,
+ "width": 732.1600000000001,
+ "height": 60.0,
+ "strokeColor": "#6741d9",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "AGENTE (claude-code-action)\nverifica ocorrências · medição vira achado · regras 7 e 9 · código defasado fora do diff\nescreve human.md (o que mudou no design, em px) + findings.json — NÃO posta",
+ "originalText": "AGENTE (claude-code-action)\nverifica ocorrências · medição vira achado · regras 7 e 9 · código defasado fora do diff\nescreve human.md (o que mudou no design, em px) + findings.json — NÃO posta",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r25",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 178687833,
+ "version": 1,
+ "versionNonce": 588527891,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a27",
+ "type": "arrow",
+ "x": 200.0,
+ "y": 505,
+ "width": 100.0,
+ "height": 75,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 100.0,
+ 75
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 438992243,
+ "version": 1,
+ "versionNonce": 1834120863,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a28",
+ "type": "arrow",
+ "x": 480.0,
+ "y": 505,
+ "width": 20.0,
+ "height": 75,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 20.0,
+ 75
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 2100238002,
+ "version": 1,
+ "versionNonce": 820080588,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a29",
+ "type": "arrow",
+ "x": 760.0,
+ "y": 505,
+ "width": 20.0,
+ "height": 75,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -20.0,
+ 75
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 866887170,
+ "version": 1,
+ "versionNonce": 1252595662,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a30",
+ "type": "arrow",
+ "x": 1040.0,
+ "y": 505,
+ "width": 100.0,
+ "height": 75,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -100.0,
+ 75
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 2126937882,
+ "version": 1,
+ "versionNonce": 2006406562,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t31",
+ "type": "text",
+ "x": 1032,
+ "y": 610,
+ "width": 109.2,
+ "height": 35.0,
+ "strokeColor": "#6741d9",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "regras: §12\n(via CLAUDE.md)",
+ "originalText": "regras: §12\n(via CLAUDE.md)",
+ "fontSize": 14,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 941989509,
+ "version": 1,
+ "versionNonce": 1925028852,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t33"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r32",
+ "type": "rectangle",
+ "x": 80,
+ "y": 745,
+ "width": 1080,
+ "height": 90,
+ "strokeColor": "#f08c00",
+ "backgroundColor": "#ffec99",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1304663356,
+ "version": 1,
+ "versionNonce": 202710383,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t33",
+ "type": "text",
+ "x": 278.88,
+ "y": 760.0,
+ "width": 682.24,
+ "height": 60.0,
+ "strokeColor": "#f08c00",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "publish.mjs\nresumo editado no lugar · inline só achado NOVO (arquivo + regra + texto da linha)\nachado fora do diff → lista no resumo · marcador oculto, não o autor",
+ "originalText": "publish.mjs\nresumo editado no lugar · inline só achado NOVO (arquivo + regra + texto da linha)\nachado fora do diff → lista no resumo · marcador oculto, não o autor",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r32",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1873884311,
+ "version": 1,
+ "versionNonce": 1376243282,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a34",
+ "type": "arrow",
+ "x": 620.0,
+ "y": 680,
+ "width": 0.0,
+ "height": 65,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 0.0,
+ 65
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1475071074,
+ "version": 1,
+ "versionNonce": 1903003137,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t35",
+ "type": "text",
+ "x": 112,
+ "y": 719,
+ "width": 290.68,
+ "height": 16.25,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "o mecânico publica mesmo se o agente falhar",
+ "originalText": "o mecânico publica mesmo se o agente falhar",
+ "fontSize": 13,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "dashed",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 239511981,
+ "version": 1,
+ "versionNonce": 1242921527,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a36",
+ "type": "arrow",
+ "x": 100,
+ "y": 505,
+ "width": 0,
+ "height": 240,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 240
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1265048621,
+ "version": 1,
+ "versionNonce": 1368842818,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t38"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r37",
+ "type": "rectangle",
+ "x": 80,
+ "y": 890,
+ "width": 520,
+ "height": 70,
+ "strokeColor": "#1971c2",
+ "backgroundColor": "#a5d8ff",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1360923469,
+ "version": 1,
+ "versionNonce": 782237321,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t38",
+ "type": "text",
+ "x": 169.44,
+ "y": 905.0,
+ "width": 341.12,
+ "height": 40.0,
+ "strokeColor": "#1971c2",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "PR\n1 comentário + review com os inline novos",
+ "originalText": "PR\n1 comentário + review com os inline novos",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r37",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 389119812,
+ "version": 1,
+ "versionNonce": 1857245613,
+ "isDeleted": false,
+ "boundElements": [
+ {
+ "type": "text",
+ "id": "t40"
+ }
+ ],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "r39",
+ "type": "rectangle",
+ "x": 640,
+ "y": 890,
+ "width": 520,
+ "height": 70,
+ "strokeColor": "#e03131",
+ "backgroundColor": "#ffc9c9",
+ "roundness": {
+ "type": 3
+ }
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 203307634,
+ "version": 1,
+ "versionNonce": 1604178334,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t40",
+ "type": "text",
+ "x": 741.92,
+ "y": 905.0,
+ "width": 316.16,
+ "height": 40.0,
+ "strokeColor": "#e03131",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "CHECK VERMELHO\nachado · agente falhou · passo quebrou",
+ "originalText": "CHECK VERMELHO\nachado · agente falhou · passo quebrou",
+ "fontSize": 16,
+ "fontFamily": 1,
+ "textAlign": "center",
+ "verticalAlign": "middle",
+ "containerId": "r39",
+ "lineHeight": 1.25,
+ "autoResize": true
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1878605648,
+ "version": 1,
+ "versionNonce": 1045025207,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a41",
+ "type": "arrow",
+ "x": 350.0,
+ "y": 835,
+ "width": 10.0,
+ "height": 55,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ -10.0,
+ 55
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 1103337275,
+ "version": 1,
+ "versionNonce": 1443645443,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "a42",
+ "type": "arrow",
+ "x": 890.0,
+ "y": 835,
+ "width": 10.0,
+ "height": 55,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roundness": {
+ "type": 2
+ },
+ "points": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 10.0,
+ 55
+ ]
+ ],
+ "lastCommittedPoint": null,
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "elbowed": false
+ },
+ {
+ "angle": 0,
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 1,
+ "opacity": 100,
+ "groupIds": [],
+ "frameId": null,
+ "seed": 416265458,
+ "version": 1,
+ "versionNonce": 581604674,
+ "isDeleted": false,
+ "boundElements": [],
+ "updated": 1,
+ "link": null,
+ "locked": false,
+ "id": "t43",
+ "type": "text",
+ "x": 640,
+ "y": 968,
+ "width": 298.48,
+ "height": 17.5,
+ "strokeColor": "#666",
+ "backgroundColor": "transparent",
+ "roundness": null,
+ "text": "“não auditou” nunca pode parecer “passou”",
+ "originalText": "“não auditou” nunca pode parecer “passou”",
+ "fontSize": 14,
+ "fontFamily": 1,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "lineHeight": 1.25,
+ "autoResize": true
+ }
+ ],
+ "appState": {
+ "gridSize": null,
+ "viewBackgroundColor": "#ffffff"
+ },
+ "files": {}
+}
\ No newline at end of file
diff --git a/design/screen-routes.json b/design/screen-routes.json
new file mode 100644
index 0000000..290b4ac
--- /dev/null
+++ b/design/screen-routes.json
@@ -0,0 +1,10 @@
+{
+ "Channel — Home": {
+ "page": "app/[handle]/page.tsx",
+ "url": "/@FullCycle"
+ },
+ "Channel — Videos": {
+ "page": "app/[handle]/videos/page.tsx",
+ "url": "/@FullCycle/videos"
+ }
+}
diff --git a/scripts/check-gate.sh b/scripts/check-gate.sh
new file mode 100755
index 0000000..153dc54
--- /dev/null
+++ b/scripts/check-gate.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Decide o que o Design check roda neste PR. Imprime key=value, no formato do
+# $GITHUB_OUTPUT, e um resumo legível em stderr.
+#
+# uso: DRAFT=true|false FORK=true|false scripts/check-gate.sh
+#
+# design o PR mexe no design ou no próprio pipeline: digests e diff
+# render design, e há PEN_CLI_KEY (fork não recebe secret): PNGs e composição
+# scan há o que auditar e o PR não é rascunho: regex sobre o código
+# deep scan, e não é fork: components.html, medição da app e agente
+# pen algum passo acima precisa do pen CLI
+# publish algum passo acima produziu algo para publicar
+#
+# Rascunho roda só o mecânico do design: quem itera no .pen vê o antes/depois
+# sem pagar build e agente a cada push. Mexer só em código num rascunho não
+# roda nada.
+set -euo pipefail
+
+base="${1:?base ref}"
+draft="${DRAFT:-false}"
+fork="${FORK:-false}"
+
+changed=$(git diff --name-only "$base...HEAD")
+touches() { printf '%s\n' "$changed" | grep -qE "$1"; }
+
+# scripts/ e o workflow entram nos dois lados: mudar o pipeline tem que
+# exercitar o pipeline inteiro.
+design=false
+touches '^(design/pendev/|scripts/|\.github/workflows/design-check\.yml$)' && design=true
+
+audit=$design
+touches '^(app/|components/|design/DESIGN-SYSTEM\.md$|design/screen-routes\.json$)' && audit=true
+
+yes() { "$@" && echo true || echo false; }
+render=$(yes [ "$design" = true -a "$fork" = false ])
+scan=$(yes [ "$audit" = true -a "$draft" = false ])
+deep=$(yes [ "$scan" = true -a "$fork" = false ])
+pen=$(yes [ "$render" = true -o "$deep" = true ])
+publish=$(yes [ "$design" = true -o "$scan" = true ])
+
+printf 'design=%s\nrender=%s\nscan=%s\ndeep=%s\npen=%s\npublish=%s\n' \
+ "$design" "$render" "$scan" "$deep" "$pen" "$publish"
+echo "arquivos: $(printf '%s\n' "$changed" | grep -c .) | rascunho=$draft fork=$fork | design=$design render=$render scan=$scan deep=$deep" >&2
diff --git a/scripts/compare-styles.mjs b/scripts/compare-styles.mjs
index f568d16..cbb283c 100644
--- a/scripts/compare-styles.mjs
+++ b/scripts/compare-styles.mjs
@@ -1,21 +1,32 @@
-// Compara os valores computados do design contra os da app.
+// Compara os valores computados do design contra os da app, em todas as
+// páginas do mapa tela -> URL.
//
// Por que computado e não className: o design exporta `rounded-[8px]` e o
// código escreve `rounded-lg`. Strings diferentes, mesmo valor. O navegador é o
// compilador que normaliza os dois dialetos — comparar getComputedStyle compara
// o que o usuário vê, não como foi escrito.
//
-// node scripts/compare-styles.mjs
+// node scripts/compare-styles.mjs
//
// O design é o lado que MANDA, e só nas propriedades que ele fixa: a classe do
// nó exportado diz quais são. Posição e largura nunca entram — o artboard é
// 1440 fixo e o layout do código é fluido por decisão (§8).
+//
+// Cada tela do .pen (screens.tsv, de pen-screens.sh) cai em um caso:
+// medida tem entrada no mapa e a page.tsx existe
+// não implementada tem entrada, mas a page.tsx ainda não existe
+// sem entrada o mapa não a cita — visível no relatório, sem ser erro
+// E um caso é erro: entrada no mapa para tela que o .pen não tem (renomeada?).
+//
+// Saída 0: sem divergência. 1: divergência (vira achado da regra 10).
+// 2: não deu para medir — CDN, página que não responde, mapa desatualizado.
import { chromium } from "playwright";
+import { existsSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
-const [designPath, appUrl] = process.argv.slice(2);
-if (!designPath || !appUrl) {
- console.error("uso: compare-styles.mjs ");
+const [designPath, origin, routesPath, screensPath] = process.argv.slice(2);
+if (!designPath || !origin || !routesPath || !screensPath) {
+ console.error("uso: compare-styles.mjs ");
process.exit(2);
}
@@ -36,8 +47,6 @@ const WATCHED = [
{ prop: "height", when: /(^|\s)h-\[\d/ },
];
-const PROPS = [...new Set(WATCHED.map((w) => w.prop))];
-
// rounded-full sai como 999px no design e como calc(infinity) no Tailwind:
// visualmente idênticos, numericamente não.
function normalize(prop, value) {
@@ -65,87 +74,149 @@ async function collect(page, attr) {
}, attr);
}
-const browser = await chromium.launch();
-const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
-
-// `networkidle` não serve: basta uma conexão persistente para nunca ficar
-// ocioso, e o goto estoura. `load` mais um sinal explícito de prontidão é
-// determinístico.
-await page.goto(pathToFileURL(designPath).href, { waitUntil: "load", timeout: 60000 });
-
-// O export do design aplica as classes via cdn.tailwindcss.com. Sem rede, a
-// página renderiza CRUA — e aí o comparador acusaria divergência em tudo. Essa
-// é a falha que precisa gritar, não passar por achado.
-try {
- await page.waitForFunction(() => {
- // não serve olhar o primeiro nó nomeado: no export ele é o sheet do board,
- // que é block. Um `.flex` computando `flex` prova que o CDN processou.
- const el = document.querySelector(".flex");
- return el && getComputedStyle(el).display === "flex";
- }, { timeout: 30000 });
-} catch {
- console.error("o Tailwind do CDN não aplicou no export do design — sem isso");
- console.error("toda propriedade viraria divergência falsa. Abortando.");
- await browser.close();
- process.exit(2);
+// --- o plano: o que medir, o que não, o que está errado no mapa ---------------
+const routes = JSON.parse(readFileSync(routesPath, "utf8"));
+const screens = readFileSync(screensPath, "utf8").split("\n").filter(Boolean)
+ .map((line) => line.split("\t")[1]);
+
+const measured = [];
+const unmeasured = [];
+const errors = [];
+for (const name of screens) {
+ const route = routes[name];
+ if (!route) unmeasured.push(`${name} (sem entrada em \`${routesPath}\`)`);
+ else if (!existsSync(route.page)) unmeasured.push(`${name} (não implementada: \`${route.page}\`)`);
+ else measured.push({ name, ...route });
}
-const design = await collect(page, "data-pencil-name");
-
-await page.goto(appUrl, { waitUntil: "load", timeout: 60000 });
-try {
- await page.waitForSelector("[data-component]", { timeout: 30000 });
-} catch {
- console.error(`nenhum [data-component] em ${appUrl} — a tela não usa os`);
- console.error("componentes do design system, ou o atributo não foi emitido.");
- await browser.close();
- process.exit(2);
+for (const name of Object.keys(routes)) {
+ if (!screens.includes(name)) {
+ errors.push(`\`${routesPath}\` cita "${name}", que não é tela no .pen — renomeada ou removida?`);
+ }
}
-const app = await collect(page, "data-component");
-
-await browser.close();
-const findings = [];
-let compared = 0;
+const report = [];
+const print = (line = "") => report.push(line);
-for (const [name, instances] of Object.entries(app)) {
- const spec = design[name]?.[0];
- if (!spec) {
- findings.push({ name, prop: "—", esperado: "existe no .pen", obtido: "não encontrado no export do design" });
- continue;
+// process.exit logo após escrever corta a saída quando stdout é pipe (e o
+// numeric.md é): o código de saída vai em exitCode e o Node sai sozinho.
+// Exceção inesperada sai 2, não 1 — o Node sairia 1, que aqui quer dizer
+// "divergência", e "quebrou" viraria "mediu e achou".
+// O navegador fecha no finally: aberto, ele segura o Node vivo depois de uma
+// exceção, e o job ficaria pendurado até o timeout.
+let browser = null;
+try {
+ process.exitCode = await main();
+} catch (err) {
+ print();
+ print(`- **erro:** a medição quebrou: ${String(err?.message ?? err).split("\n")[0]}`);
+ process.exitCode = 2;
+} finally {
+ await browser?.close().catch(() => {});
+}
+console.log(report.join("\n"));
+
+async function main() {
+ print("### Comparação numérica");
+ print();
+ print(`medidas: ${measured.map((m) => `${m.name} (\`${m.url}\`)`).join(", ") || "_nenhuma_"}`);
+ if (unmeasured.length) print(`não medidas: ${unmeasured.join(", ")}`);
+
+ if (measured.length === 0) {
+ print();
+ print("_nenhuma página implementada no mapa — comparação pulada_");
+ for (const e of errors) print(`- **erro:** ${e}`);
+ return errors.length ? 2 : 0;
}
- // quais propriedades o design fixa neste nó
- const watch = WATCHED.filter((w) => w.when.test(spec.className)).map((w) => w.prop);
- if (watch.length === 0) continue;
-
- // Uma variante pode divergir legitimamente — o Chip ativo inverte as cores —
- // então basta que UMA instância bata com o padrão do design.
- const score = (inst) =>
- watch.filter((p) => normalize(p, inst.style[p]) !== normalize(p, spec.style[p])).length;
-
- compared += 1;
- const best = instances.reduce((a, b) => (score(b) < score(a) ? b : a));
- if (score(best) === 0) continue;
-
- // Reportar a instância que menos diverge, e não a primeira: senão a diferença
- // legítima de uma variante entra no relatório como se fosse defeito.
- for (const p of watch) {
- const want = normalize(p, spec.style[p]);
- const got = normalize(p, best.style[p]);
- if (want !== got) findings.push({ name, prop: p, esperado: want, obtido: got });
+ // --- medir ------------------------------------------------------------------
+ browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+
+ // `networkidle` não serve: basta uma conexão persistente para nunca ficar
+ // ocioso, e o goto estoura. `load` mais um sinal explícito de prontidão é
+ // determinístico.
+ await page.goto(pathToFileURL(designPath).href, { waitUntil: "load", timeout: 60000 });
+
+ // O export do design aplica as classes via cdn.tailwindcss.com. Sem rede, a
+ // página renderiza CRUA — e aí o comparador acusaria divergência em tudo. Essa
+ // é a falha que precisa gritar, não passar por achado.
+ try {
+ await page.waitForFunction(() => {
+ // não serve olhar o primeiro nó nomeado: no export ele é o sheet do board,
+ // que é block. Um `.flex` computando `flex` prova que o CDN processou.
+ const el = document.querySelector(".flex");
+ return el && getComputedStyle(el).display === "flex";
+ }, { timeout: 30000 });
+ } catch {
+ print();
+ print("- **erro:** o Tailwind do CDN não aplicou no export do design — sem isso toda propriedade viraria divergência falsa. Medição abortada.");
+ return 2;
}
-}
+ const design = await collect(page, "data-pencil-name");
+
+ // Instâncias de todas as páginas juntas: o mesmo componente aparece em várias,
+ // e basta uma instância bater com o design (ver score, abaixo).
+ const app = {};
+ for (const route of measured) {
+ const url = new URL(route.url, origin).href;
+ try {
+ const response = await page.goto(url, { waitUntil: "load", timeout: 60000 });
+ if (!response || !response.ok()) throw new Error(`HTTP ${response?.status() ?? "sem resposta"}`);
+ await page.waitForSelector("[data-component]", { timeout: 30000 });
+ } catch (err) {
+ errors.push(`${route.name}: \`${route.url}\` não carregou com [data-component] (${err.message.split("\n")[0]})`);
+ continue;
+ }
+ for (const [name, instances] of Object.entries(await collect(page, "data-component"))) {
+ (app[name] ??= []).push(...instances.map((inst) => ({ ...inst, url: route.url })));
+ }
+ }
+ // --- comparar -----------------------------------------------------------------
+ const findings = [];
+ let compared = 0;
+
+ for (const [name, instances] of Object.entries(app)) {
+ const spec = design[name]?.[0];
+ if (!spec) {
+ findings.push({ name, prop: "—", want: "existe no .pen", got: "não encontrado no export do design", url: instances[0].url });
+ continue;
+ }
-console.log(`componentes comparados: ${compared}`);
-if (findings.length === 0) {
- console.log("\nnenhuma divergência numérica");
- process.exit(0);
-}
+ // quais propriedades o design fixa neste nó
+ const watch = WATCHED.filter((w) => w.when.test(spec.className)).map((w) => w.prop);
+ if (watch.length === 0) continue;
+
+ // Uma variante pode divergir legitimamente — o Chip ativo inverte as cores —
+ // então basta que UMA instância bata com o padrão do design.
+ const score = (inst) =>
+ watch.filter((p) => normalize(p, inst.style[p]) !== normalize(p, spec.style[p])).length;
+
+ compared += 1;
+ const best = instances.reduce((a, b) => (score(b) < score(a) ? b : a));
+ if (score(best) === 0) continue;
+
+ // Reportar a instância que menos diverge, e não a primeira: senão a diferença
+ // legítima de uma variante entra no relatório como se fosse defeito.
+ for (const p of new Set(watch)) {
+ const want = normalize(p, spec.style[p]);
+ const got = normalize(p, best.style[p]);
+ if (want !== got) findings.push({ name, prop: p, want, got, url: best.url });
+ }
+ }
-console.log(`\n## divergência numérica design <-> código (${findings.length})\n`);
-console.log("| componente | propriedade | design | código |");
-console.log("|---|---|---|---|");
-for (const f of findings) {
- console.log(`| \`${f.name}\` | ${f.prop} | \`${f.esperado}\` | \`${f.obtido}\` |`);
+ print(`componentes comparados: ${compared}`);
+ for (const e of errors) print(`- **erro:** ${e}`);
+ print();
+ if (findings.length === 0) {
+ print("nenhuma divergência numérica");
+ } else {
+ print(`#### divergência numérica design <-> código (${findings.length})`);
+ print();
+ print("| componente | propriedade | design | código | página |");
+ print("|---|---|---|---|---|");
+ for (const f of findings) {
+ print(`| \`${f.name}\` | ${f.prop} | \`${f.want}\` | \`${f.got}\` | \`${f.url}\` |`);
+ }
+ }
+ return errors.length ? 2 : findings.length ? 1 : 0;
}
-process.exit(1);
diff --git a/scripts/design-diff.sh b/scripts/design-diff.sh
new file mode 100755
index 0000000..72a294e
--- /dev/null
+++ b/scripts/design-diff.sh
@@ -0,0 +1,166 @@
+#!/usr/bin/env bash
+# O que mudou no design entre dois .pen: digests, telas e composição.
+#
+# uso: scripts/design-diff.sh
+# RENDER=false scripts/design-diff.sh ... # sem PEN_CLI_KEY (PR de fork)
+#
+# O .pen da base precisa estar dentro da própria árvore (um worktree, não um
+# `git show` para /tmp): ele resolve ./assets/* relativo a si mesmo, e o render
+# sairia sem imagem nenhuma, em silêncio.
+#
+# Em :
+# base/ head/ digests (pen-digest.sh), screens.tsv, png/, outline/
+# screens.md a tabela de telas que vai no comentário
+# changed.tsv das telas cujo render mudou
+# summary.md o diff exato: tokens, inventário, componentes, composição
+# artifact/ só o que difere: PNGs e outlines das telas, mais os digests
+#
+# Quatro camadas; o diff do arquivo inteiro seria ruído de coordenada.
+#
+# tokens cor/escala trocada, token ou tema adicionado/removido
+# inventário componente adicionado/removido/renomeado
+# componentes mudança DENTRO de um componente (o padding do Chip), que não
+# aparece nas duas anteriores
+# composição o que mudou DENTRO de uma tela: componente que entrou, saiu ou
+# trocou de lugar, gap e padding de container
+set -euo pipefail
+
+here=$(dirname "$0")
+base_pen="${1:?base.pen}"
+head_pen="${2:?head.pen}"
+out="${3:?diretório de saída}"
+render="${RENDER:-true}"
+
+slug() { printf '%s' "$1" | LC_ALL=C tr -cs 'A-Za-z0-9' '-' | sed 's/^-//; s/-$//' | tr 'A-Z' 'a-z'; }
+pen_of() { [ "$1" = base ] && echo "$base_pen" || echo "$head_pen"; }
+# awk e não `cut | grep -q`: sob pipefail, o grep que sai cedo dá SIGPIPE no cut
+has_screen() { awk -F'\t' -v n="$2" '$2 == n { found = 1 } END { exit !found }' "$out/$1/screens.tsv"; }
+
+rm -rf "$out"
+mkdir -p "$out/base" "$out/head" "$out/artifact"
+
+# --- telas ------------------------------------------------------------------
+# A lista sai de cada lado, não de uma constante: uma lista só para os dois
+# deixaria tela nova fora do render, e pôr a tela nova nela derrubaria o render
+# da base, onde ela ainda não existe. Identidade é o NOME: tela renomeada
+# aparece como removida + adicionada.
+for side in base head; do
+ pen=$(pen_of "$side")
+ "$here/pen-digest.sh" "$pen" "$out/$side"
+ "$here/pen-screens.sh" "$pen" > "$out/$side/screens.tsv"
+ if [ "$render" != true ] || [ ! -s "$out/$side/screens.tsv" ]; then continue; fi
+
+ PEN_NODES=$(cut -f2 "$out/$side/screens.tsv" | paste -sd';') \
+ "$here/pen-export.sh" "$pen" "$out/$side/png" 1 >&2
+ # o Export grava .png; aqui o arquivo passa a ter o nome da tela
+ while IFS=$'\t' read -r id name; do
+ dest="$out/$side/png/$(slug "$name").png"
+ if [ -e "$dest" ]; then
+ echo "duas telas viram $(basename "$dest") em $side — renomeie uma no design" >&2
+ exit 1
+ fi
+ mv "$out/$side/png/$id.png" "$dest"
+ done < "$out/$side/screens.tsv"
+done
+
+# Mesmo CLI, mesma máquina: o render é determinístico, e bytes iguais são tela
+# igual. Só o que difere vai para o artefato — com muitas telas, os pares
+# idênticos afogariam as poucas que mudaram.
+rows=""; same=""; unrendered=""; : > "$out/changed.tsv"
+while IFS= read -r name; do
+ s=$(slug "$name")
+ b="$out/base/png/$s.png"; h="$out/head/png/$s.png"
+ if ! has_screen base "$name"; then
+ state=adicionada; file="head/$s.png"
+ if [ "$render" = true ]; then install -D "$h" "$out/artifact/$file"; fi
+ elif ! has_screen head "$name"; then
+ state=removida; file="base/$s.png"
+ if [ "$render" = true ]; then install -D "$b" "$out/artifact/$file"; fi
+ elif [ "$render" != true ]; then
+ unrendered="${unrendered:+$unrendered, }$name"; continue
+ elif cmp -s "$b" "$h"; then
+ same="${same:+$same, }$name"; continue
+ else
+ state=mudou; file="{base,head}/$s.png"
+ install -D "$b" "$out/artifact/base/$s.png"
+ install -D "$h" "$out/artifact/head/$s.png"
+ printf '%s\t%s\n' "$s" "$name" >> "$out/changed.tsv"
+ fi
+ if [ "$render" != true ]; then file="—"; fi
+ rows+="| $name | $state | \`$file\` |"$'\n'
+done < <(cat "$out/base/screens.tsv" "$out/head/screens.tsv" | cut -f2 | LC_ALL=C sort -u)
+
+{
+ echo "### Telas"; echo
+ if [ -n "$rows" ]; then
+ echo "| tela | render | no artefato |"; echo "|---|---|---|"
+ printf '%s' "$rows"
+ else
+ echo "_nenhuma tela mudou no render_"
+ fi
+ if [ -n "$same" ]; then echo; echo "Sem mudança no render: $same"; fi
+ if [ -n "$unrendered" ]; then echo; echo "Não comparadas (render desligado, sem PEN_CLI_KEY): $unrendered"; fi
+} > "$out/screens.md"
+
+# --- diff exato ---------------------------------------------------------------
+# Comentário de PR estoura em 65536 chars; cada seção tem teto próprio.
+emit() { # título, arquivo relativo a base/ e head/, teto de linhas
+ local max=${3:-120} d n
+ echo "### $1"; echo
+ if diff -q "$out/base/$2" "$out/head/$2" >/dev/null; then
+ echo '_sem mudança_'
+ else
+ # diff sai 1 quando há diferença, que é justamente o caso: sob pipefail,
+ # sem o `|| true` isso derruba o script em silêncio
+ d=$(diff -u "$out/base/$2" "$out/head/$2" | tail -n +3 || true)
+ n=$(printf '%s\n' "$d" | wc -l)
+ echo '```diff'
+ printf '%s\n' "$d" | head -"$max"
+ echo '```'
+ if [ "$n" -gt "$max" ]; then echo "_+$((n - max)) linhas omitidas — veja o artefato._"; fi
+ fi
+ echo
+}
+
+# Composição só das telas cujo render mudou, sobre o esqueleto do
+# pen-outline.py: a árvore instanciada daria milhares de linhas, e para tela
+# igual o diff seria vazio de qualquer jeito. Tela nova ou removida não entra —
+# o diff seria a tela inteira. Render diferente com composição igual é mudança
+# de componente ou token, e as camadas acima dizem qual: essas telas viram uma
+# linha, senão uma troca de token daria N seções vazias. Composição mudando em
+# muitas telas de uma vez é raro; o teto de 5 seções segura o comentário.
+composition() {
+ local same="" over="" shown=0 s name f
+ while IFS=$'\t' read -r s name; do
+ f="outline/$s.json"
+ mkdir -p "$out/base/outline" "$out/head/outline"
+ "$here/pen-outline.py" "$base_pen" "$name" > "$out/base/$f"
+ "$here/pen-outline.py" "$head_pen" "$name" > "$out/head/$f"
+ install -D "$out/base/$f" "$out/artifact/base/$f"
+ install -D "$out/head/$f" "$out/artifact/head/$f"
+ if diff -q "$out/base/$f" "$out/head/$f" >/dev/null; then
+ same="${same:+$same, }$name"
+ elif [ "$shown" -lt 5 ]; then
+ emit "Composição — $name" "$f" 60; shown=$((shown + 1))
+ else
+ over="${over:+$over, }$name"
+ fi
+ done < "$out/changed.tsv"
+ if [ -n "$over" ]; then echo "Composição mudou também em: $over — diff no artefato."; echo; fi
+ if [ -n "$same" ]; then echo "Render mudou, composição igual (a causa está nas camadas acima): $same"; echo; fi
+}
+
+{
+ emit Tokens tokens.json
+ emit "Inventário de componentes" inventory.txt
+ emit "Estrutura dos componentes" components.json
+ composition
+} > "$out/summary.md"
+
+for side in base head; do
+ for f in tokens.json inventory.txt components.json screens.tsv; do
+ install -D "$out/$side/$f" "$out/artifact/$side/$f"
+ done
+done
+
+cat "$out/screens.md"; echo; cat "$out/summary.md"
diff --git a/scripts/drift-scan.sh b/scripts/drift-scan.sh
index b68907d..f9d1ede 100755
--- a/scripts/drift-scan.sh
+++ b/scripts/drift-scan.sh
@@ -1,9 +1,15 @@
#!/usr/bin/env bash
-# Pré-análise mecânica do drift. Seis das nove regras do DESIGN-SYSTEM.md §12
+# Varredura mecânica do código. Das dez regras do DESIGN-SYSTEM.md §12, sete
# são regex sobre o diff — fazê-las aqui é determinístico, grátis, e poupa o
-# agente para as três que exigem julgamento contra o design.
+# agente para as que exigem julgamento contra o design. A saída são
+# OCORRÊNCIAS, não achados: `rounded-lg` pode estar certo; quem decide é o
+# agente, ou quem lê o relatório de um PR de fork.
#
# uso: scripts/drift-scan.sh [arquivos...]
+# INVENTORY= ... # digest já pronto (pen-digest.sh)
+#
+# O lado do design (o que mudou no .pen) não é daqui: está no summary.md do
+# design-diff.sh, calculado uma vez para o diff e para a auditoria.
set -uo pipefail
BASE="${1:?base ref}"; shift
@@ -11,69 +17,40 @@ FILES=("$@")
if [ ${#FILES[@]} -eq 0 ]; then
mapfile -t FILES < <(git diff --name-only "$BASE...HEAD" -- 'app/**' 'components/**' | grep -E '\.(tsx|ts|css)$' || true)
fi
-# O design também pode ter andado sozinho. Nesse caso o diff de código é
-# vazio, mas o código pode ter ficado defasado — e é o caso que mais escapa,
-# porque nenhum arquivo de código aparece no PR para chamar atenção.
-design_side() {
- local pen="${PEN_FILE:-design/pendev/youtube-channel.pen}"
- git diff --quiet "$BASE...HEAD" -- "$pen" && return
- local strip='walk(if type == "object" then del(.id, .x, .y) else . end)'
- git show "$BASE:$pen" > /tmp/base.pen 2>/dev/null || return
-
- jq -S '.variables // {}' /tmp/base.pen > /tmp/base.tok
- jq -S '.variables // {}' "$pen" > /tmp/head.tok
- # com contexto: sem ele o diff mostra "10 -> 12" sem dizer QUAL token
- local tok
- tok=$(diff -U4 /tmp/base.tok /tmp/head.tok | tail -n +3 || true)
- [ -n "$tok" ] && printf '\n## design mudou — TOKENS (transcreva para app/globals.css, §13)\n%s\n' "$tok"
-
- # quais componentes mudaram por dentro
- local changed=""
- while read -r name; do
- [ -z "$name" ] && continue
- a=$(jq -S --arg n "$name" "[.. | objects | select(.reusable == true and .name == \$n)] | map($strip)" /tmp/base.pen)
- b=$(jq -S --arg n "$name" "[.. | objects | select(.reusable == true and .name == \$n)] | map($strip)" "$pen")
- [ "$a" != "$b" ] && changed="$changed$name"$'\n'
- done < <(jq -r '[.. | objects | select(.reusable == true) | .name] | .[]' "$pen")
-
- if [ -n "$changed" ]; then
- printf '\n## design mudou — COMPONENTES (confira o .tsx de mesmo nome)\n%s\n' "$changed"
- fi
-}
+echo "### Varredura mecânica"; echo
if [ ${#FILES[@]} -eq 0 ]; then
- echo "nenhum arquivo de código no diff"
- { design_side; } > /tmp/drift-scan.txt
- if [ -s /tmp/drift-scan.txt ]; then
- echo; echo "=== mas o design andou ==="; cat /tmp/drift-scan.txt
- fi
+ echo "_nenhum arquivo de código no diff_"
exit 0
fi
+echo "arquivos analisados: ${#FILES[@]}"
+printf '%s\n' "${FILES[@]}" | sed 's/^/- `/; s/$/`/'
# Descarta linhas de comentário: o próprio código cita as regras nos
# comentários ("NÃO os 56px de p-14"), e isso casaria com os padrões.
strip_comments() {
- awk -F: '{ line = $0; sub(/^[^:]*:[0-9]+:/, "", line);
- gsub(/^[ \t]+/, "", line);
- if (line !~ /^(\/\/|\*|\/\*)/) print }'
+ awk '{ line = $0; sub(/^[^:]*:[0-9]+:/, "", line);
+ gsub(/^[ \t]+/, "", line);
+ if (line !~ /^(\/\/|\*|\/\*)/) print }'
}
+found=0
hit() { # regra, descrição, padrão
local out
- out=$(grep -nE "$3" "${FILES[@]}" 2>/dev/null | strip_comments || true)
+ out=$(grep -HnE "$3" "${FILES[@]}" 2>/dev/null | strip_comments || true)
if [ -n "$out" ]; then
- printf '\n## regra %s — %s\n%s\n' "$1" "$2" "$out"
+ found=1
+ printf '\n#### regra %s do §12 — %s\n```\n%s\n```\n' "$1" "$2" "$out"
fi
}
-echo "arquivos analisados: ${#FILES[@]}"
-echo "${FILES[@]}" | tr ' ' '\n' | sed 's/^/ /'
-
-{
hit 1 "cor crua em className (quebra o tema escuro)" \
'className=[^>]*(#[0-9a-fA-F]{3,8}|rgb\(|hsl\()'
hit 2 "variante dark: (falta token semântico)" \
'\bdark:'
+# rounded-nav e rounded-full não são ambíguos; os outros colidem com o .pen
+hit 3 "radius na zona de colisão — conferir contra a tabela do §6" \
+ '\brounded-(xs|sm|md|lg|xl|2xl|3xl)\b'
hit 4 "p-14 e afins — \$space-14 é 58px, p-14 é 56px" \
'\b[pmg][xytrbl]?-14\b'
hit 5 "nome de tamanho do Tailwind que NÃO bate com o .pen" \
@@ -82,29 +59,30 @@ hit 6 "altura fixa onde o design é 16:9 — use aspect-video" \
'h-\[[0-9]+px\]'
hit 8 "prop className exposta (componente deve ser fechado)" \
'className\?:'
-# rounded-nav e rounded-full não são ambíguos; os outros colidem com o .pen
-hit 3 "radius na zona de colisão — conferir contra a tabela do §6" \
- '\brounded-(xs|sm|md|lg|xl|2xl|3xl)\b'
-design_side
-} > /tmp/drift-scan.txt
# Regra 7, metade mecânica: todo data-component precisa existir como frame
# reusable no .pen. O que o design não nomeia, o código não deveria inventar.
-PEN="${PEN_FILE:-design/pendev/youtube-channel.pen}"
-if [ -f "$PEN" ]; then
- jq -r '[.. | objects | select(.reusable == true) | .name] | sort | .[]' "$PEN" > /tmp/pen-names.txt
- grep -ohE 'data-component="[^"]+"' "${FILES[@]}" 2>/dev/null \
- | sed 's/data-component="//; s/"$//' | sort -u > /tmp/code-names.txt
- unknown=$(comm -23 /tmp/code-names.txt /tmp/pen-names.txt)
- if [ -n "$unknown" ]; then
- printf '\n## regra 7 — data-component sem frame correspondente no .pen\n%s\n' "$unknown" >> /tmp/drift-scan.txt
- fi
- printf '\n## componentes do design presentes neste diff\n%s\n' \
- "$(comm -12 /tmp/code-names.txt /tmp/pen-names.txt)" >> /tmp/drift-scan.txt
+inventory="${INVENTORY:-}"
+if [ -z "$inventory" ]; then
+ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
+ "$(dirname "$0")/pen-digest.sh" "${PEN_FILE:-design/pendev/youtube-channel.pen}" "$tmp"
+ inventory="$tmp/inventory.txt"
+fi
+code_names=$(grep -ohE 'data-component="[^"]+"' "${FILES[@]}" 2>/dev/null \
+ | sed 's/data-component="//; s/"$//' | LC_ALL=C sort -u)
+sorted_inventory=$(LC_ALL=C sort "$inventory")
+unknown=$(LC_ALL=C comm -23 <(printf '%s\n' "$code_names" | grep .) <(printf '%s\n' "$sorted_inventory"))
+if [ -n "$unknown" ]; then
+ found=1
+ printf '\n#### regra 7 do §12 — data-component sem frame correspondente no .pen\n%s\n' \
+ "$(printf '%s\n' "$unknown" | sed 's/^/- `/; s/$/`/')"
+fi
+present=$(LC_ALL=C comm -12 <(printf '%s\n' "$code_names" | grep .) <(printf '%s\n' "$sorted_inventory"))
+if [ -n "$present" ]; then
+ printf '\n#### componentes do design presentes neste diff\n%s\n' \
+ "$(printf '%s\n' "$present" | sed 's/^/- `/; s/$/`/')"
fi
-if [ -s /tmp/drift-scan.txt ]; then
- echo; echo "=== ocorrências para o agente verificar ==="; cat /tmp/drift-scan.txt
-else
- echo; echo "=== nenhuma ocorrência mecânica ==="
+if [ "$found" = 0 ]; then
+ echo; echo "_nenhuma ocorrência mecânica_"
fi
diff --git a/scripts/measure-app.sh b/scripts/measure-app.sh
new file mode 100755
index 0000000..3259d75
--- /dev/null
+++ b/scripts/measure-app.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Sobe a app e mede os componentes nas páginas de design/screen-routes.json.
+#
+# uso: scripts/measure-app.sh > numeric.md
+# APP_ORIGIN=http://localhost:3100 SKIP_BUILD=1 ... # app já rodando
+#
+# Saída 0: mediu (com ou sem divergência — divergência é achado, não falha).
+# Saída 2: não conseguiu medir. A diferença importa: "não mediu" não pode
+# parecer "mediu e está tudo certo".
+set -uo pipefail
+
+html="${1:?components.html}"
+screens="${2:?screens.tsv}"
+routes="${SCREEN_ROUTES:-design/screen-routes.json}"
+origin="${APP_ORIGIN:-http://localhost:3000}"
+here=$(dirname "$0")
+
+compare() {
+ node "$here/compare-styles.mjs" "$html" "$origin" "$routes" "$screens"
+ local code=$?
+ [ "$code" -eq 2 ] && exit 2
+ exit 0
+}
+
+# Nenhuma página do mapa existe ainda: o comparador só lista o que não mediu,
+# sem abrir navegador. Não vale pagar install e build para isso.
+implemented=$(jq -r '.[].page' "$routes" | while read -r page; do [ -f "$page" ] && echo "$page"; done)
+if [ -z "$implemented" ]; then
+ compare
+fi
+
+if [ -z "${SKIP_BUILD:-}" ]; then
+ # `npm ci` exige lock em sincronia, e npm de versões diferentes resolvem
+ # dependências nativas opcionais (@emnapi/*) de formas diferentes. Isto é
+ # verificação, não build de release: se o lock não bater, instalar mesmo
+ # assim é melhor que não medir nada.
+ { npm ci || npm install --no-audit --no-fund; } >&2 || exit 2
+ npx playwright install chromium >&2 || exit 2
+ npm run build >&2 || { echo "### Comparação numérica"; echo; echo "- **erro:** \`npm run build\` falhou"; exit 2; }
+
+ port=$(node -e "console.log(new URL('$origin').port || 80)")
+ npx next start -p "$port" >&2 &
+ server=$!
+ trap 'kill $server 2>/dev/null' EXIT
+
+ for _ in $(seq 1 40); do
+ curl -sf -o /dev/null "$origin/" && break
+ sleep 2
+ done
+ if ! curl -sf -o /dev/null "$origin/"; then
+ echo "### Comparação numérica"; echo; echo "- **erro:** a app não subiu em \`$origin\`"
+ exit 2
+ fi
+fi
+
+compare
diff --git a/scripts/pen-digest.sh b/scripts/pen-digest.sh
new file mode 100755
index 0000000..231b05b
--- /dev/null
+++ b/scripts/pen-digest.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Digests textuais de um .pen. É o ÚNICO lugar onde estes filtros existem: o
+# diff do design e a auditoria leem daqui, então não têm como discordar sobre
+# o que é um token ou um componente.
+#
+# uso: scripts/pen-digest.sh
+#
+# tokens.json {themes, variables}: o que cada token vale, em cada tema
+# inventory.txt nomes dos componentes (`reusable`), ordenados
+# components.json cada `reusable` inteiro, ordenado por nome, sem id/x/y
+#
+# id/x/y saem porque mudam a cada arrasto no editor e não se traduzem em
+# código: com eles, o diff seria ruído de coordenada.
+set -euo pipefail
+
+pen="${1:?arquivo .pen}"
+out="${2:?diretório de saída}"
+mkdir -p "$out"
+
+jq -S '{themes, variables}' "$pen" > "$out/tokens.json"
+jq -r '[.. | objects | select(.reusable == true) | .name] | sort | .[]' \
+ "$pen" > "$out/inventory.txt"
+jq -S '[.. | objects | select(.reusable == true)] | sort_by(.name)
+ | map(walk(if type == "object" then del(.id, .x, .y) else . end))' \
+ "$pen" > "$out/components.json"
diff --git a/scripts/pen-export.sh b/scripts/pen-export.sh
index 8eb9060..cd2f48d 100755
--- a/scripts/pen-export.sh
+++ b/scripts/pen-export.sh
@@ -4,7 +4,10 @@
# uso: scripts/pen-export.sh [escala] [formato]
# PEN_NODES="Nó A;Nó B" scripts/pen-export.sh design.pen ./out 1 png
#
-# formato png|jpeg|webp|pdf -> é um DIRETÓRIO, um arquivo por nó
+# Sem PEN_NODES, exporta todas as telas do arquivo (scripts/pen-screens.sh).
+#
+# formato png|jpeg|webp|pdf -> é um DIRETÓRIO, um arquivo por nó,
+# nomeado pelo ID do nó (.png), não pelo nome
# formato html-tailwind|html-css -> é um ARQUIVO, todos os nós nele
#
# Requer pen.dev CLI >= 0.3.5. Em 0.3.2 um .pen com fills de imagem relativos
@@ -16,7 +19,11 @@ PEN_FILE=$(realpath "${1:?arquivo .pen}")
OUT_DIR=$(realpath -m "${2:?diretório de saída}")
SCALE="${3:-1}"
FORMAT="${4:-png}"
-NODES="${PEN_NODES:-Channel — Videos;Channel — Home}"
+NODES="${PEN_NODES:-$("$(dirname "$0")/pen-screens.sh" "$PEN_FILE" | cut -f2 | paste -sd';')}"
+if [ -z "$NODES" ]; then
+ echo "nenhum nó para exportar em $PEN_FILE" >&2
+ exit 1
+fi
have=$(pen version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1)
need=0.3.5
diff --git a/scripts/pen-outline.py b/scripts/pen-outline.py
index 08119b4..0a1e178 100755
--- a/scripts/pen-outline.py
+++ b/scripts/pen-outline.py
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Extrai o esqueleto de composição das telas de um .pen.
- scripts/pen-outline.py [prefixo-do-nome]
+ scripts/pen-outline.py [nome da tela ...]
+
+Sem nomes, todas as telas. O que é tela quem decide é scripts/pen-screens.sh —
+nome pedido que não for tela é erro, não saída vazia.
As telas não são `reusable`, então ficam fora do digest de componentes. Mas o
que interessa numa tela é a COMPOSIÇÃO — quais componentes, em que ordem, dentro
@@ -12,18 +15,32 @@
- fill de imagem vira "" pelo mesmo motivo
- id/x/y não entram: coordenada de artboard não se traduz em layout fluido
-Resultado: ~460 linhas para as duas telas, contra ~2000 da árvore crua.
+Resultado: ~460 linhas para as duas telas de canal, contra ~1700 da árvore
+crua. Tela montada à mão, sem componente, poda menos: o Privacy Policy dá ~1500.
"""
-import json, sys
+import json, subprocess, sys
+from pathlib import Path
LAYOUT_KEYS = ["layout", "gap", "padding", "alignItems", "justifyContent",
"width", "height", "cornerRadius", "fill", "stroke",
"strokeWidth", "clip"]
+def list_screens(path: str) -> dict[str, str]:
+ """nome -> id, na ordem do documento."""
+ out = subprocess.run([str(Path(__file__).with_name("pen-screens.sh")), path],
+ check=True, capture_output=True, text=True).stdout
+ return {name: id for id, name in (line.split("\t", 1) for line in out.splitlines() if line)}
+
+
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else "design/pendev/youtube-channel.pen"
- prefix = sys.argv[2] if len(sys.argv) > 2 else "Channel — "
+ screens = list_screens(path)
+ wanted = sys.argv[2:] or list(screens)
+ missing = [name for name in wanted if name not in screens]
+ if missing:
+ print(f"não são telas em {path}: {missing}", file=sys.stderr)
+ return 1
doc = json.load(open(path, encoding="utf8"))
by_id: dict[str, dict] = {}
@@ -62,14 +79,13 @@ def prune(node):
out["children"] = kids
return out
- screens = [prune(n) for n in doc["children"]
- if isinstance(n.get("name"), str) and n["name"].startswith(prefix)]
-
- if not screens:
- print(f"nenhuma tela com prefixo {prefix!r} em {path}", file=sys.stderr)
+ if not wanted:
+ print(f"nenhuma tela em {path}", file=sys.stderr)
return 1
- print(json.dumps(screens, indent=2, ensure_ascii=False))
+ ids = {screens[name] for name in wanted}
+ outline = [prune(n) for n in doc["children"] if n.get("id") in ids]
+ print(json.dumps(outline, indent=2, ensure_ascii=False))
return 0
diff --git a/scripts/pen-screens.sh b/scripts/pen-screens.sh
new file mode 100755
index 0000000..85f804c
--- /dev/null
+++ b/scripts/pen-screens.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Lista as telas de um .pen, uma por linha: "".
+#
+# uso: scripts/pen-screens.sh
+#
+# Tela é frame de topo que não é `reusable` e não é peça do board. O board é
+# excluído por nome, e não o contrário, de propósito: se aparecer uma folha
+# nova de anotação, ela vira um PNG a mais no relatório — visível. Uma lista
+# de telas por nome falharia do outro jeito: tela nova some, em silêncio.
+#
+# Por que não `width == 1440`: é verdade hoje (§8), mas a primeira tela mobile
+# sumiria do relatório sem ninguém perceber.
+set -euo pipefail
+
+jq -r '
+ .children[]
+ | select(.type == "frame" and .reusable != true)
+ | select(.name | test("^(Sheet|Label) / |^Foundations$") | not)
+ | "\(.id)\t\(.name)"
+' "${1:?arquivo .pen}"
diff --git a/scripts/publish.mjs b/scripts/publish.mjs
new file mode 100644
index 0000000..6006f43
--- /dev/null
+++ b/scripts/publish.mjs
@@ -0,0 +1,290 @@
+// Publica o resultado do Design check. O agente não posta nada: ele escreve
+// arquivos, e este script publica — então a publicação é determinística, sai
+// mesmo se o agente falhar, e dá para deduplicar entre pushes.
+//
+// node scripts/publish.mjs
+//
+// Env do Actions: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_EVENT_PATH,
+// GITHUB_API_URL, GITHUB_SERVER_URL, GITHUB_RUN_ID, GITHUB_STEP_SUMMARY.
+// Env do workflow:
+// CHECK_MODE pr (comenta no PR) | summary (PR de fork: token só leitura,
+// o relatório vai para o job summary)
+// CHECK_DRAFT true | false
+// AGENT_OUTCOME outcome do passo do agente; vazio ou skipped = não rodou
+//
+// Lê de , tudo opcional — publica o que existir:
+// design/screens.md design/summary.md design/artifact/ design-diff.sh
+// numeric.md scan.md auditoria
+// human.md findings.json agente
+//
+// Escreve /verdict.json, que o passo final usa para o status.
+//
+// Por que marcadores ocultos e não o autor: o autor depende do token (os
+// inline antigos saíam como claude[bot], os comentários como
+// github-actions[bot]), e comentário que fica outdated perde o `line` — só o
+// texto guarda a identidade.
+import { createHash } from "node:crypto";
+import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { join, normalize } from "node:path";
+
+const MARKER = "";
+const FINDING_MARKER = //;
+const COMMENT_LIMIT = 65000; // o GitHub recusa corpo acima de 65536 chars
+
+const work = process.argv[2];
+if (!work) {
+ console.error("uso: publish.mjs ");
+ process.exit(2);
+}
+const env = process.env;
+const mode = env.CHECK_MODE ?? "pr";
+const draft = env.CHECK_DRAFT === "true";
+const agentRan = !!env.AGENT_OUTCOME && env.AGENT_OUTCOME !== "skipped";
+
+const read = (rel) => {
+ const path = join(work, rel);
+ return existsSync(path) ? readFileSync(path, "utf8").trim() : null;
+};
+
+// --- achados ------------------------------------------------------------------
+// Cada achado é validado contra o repositório: arquivo que não existe ou linha
+// fora do arquivo é erro do agente, e não pode virar comentário.
+function loadFindings() {
+ const raw = read("findings.json");
+ if (!agentRan) return { valid: [], invalid: [], missing: false };
+ if (raw === null) return { valid: [], invalid: [], missing: true };
+ let list;
+ try {
+ list = JSON.parse(raw);
+ } catch {
+ return { valid: [], invalid: [], missing: true };
+ }
+ if (!Array.isArray(list)) return { valid: [], invalid: [], missing: true };
+
+ const valid = [];
+ const invalid = [];
+ for (const f of list) {
+ const reason = validate(f);
+ if (reason) invalid.push({ finding: f, reason });
+ else valid.push({ ...f, path: normalize(f.path), key: keyOf(normalize(f.path), f) });
+ }
+ return { valid, invalid, missing: false };
+}
+
+function validate(f) {
+ if (!f || typeof f !== "object") return "não é objeto";
+ if (typeof f.path !== "string" || !f.path) return "path ausente";
+ const path = normalize(f.path);
+ if (path.startsWith("..") || path.startsWith("/")) return `path fora do repositório: ${f.path}`;
+ if (!existsSync(path) || !statSync(path).isFile()) return `arquivo não existe: ${f.path}`;
+ if (!Number.isInteger(f.line) || f.line < 1) return `line inválida: ${f.line}`;
+ const lines = readFileSync(path, "utf8").split("\n").length;
+ if (f.line > lines) return `line ${f.line} além do fim de ${f.path} (${lines} linhas)`;
+ if (!Number.isInteger(f.rule) || f.rule < 1 || f.rule > 10) return `rule fora de 1–10: ${f.rule}`;
+ if (typeof f.title !== "string" || !f.title.trim()) return "title vazio";
+ if (typeof f.body !== "string" || !f.body.trim()) return "body vazio";
+ return null;
+}
+
+// A chave sobrevive a linhas inseridas acima (não usa o número da linha) e a
+// o agente escrever o mesmo achado com outras palavras (não usa o texto dele).
+// Dois achados da mesma regra em linhas idênticas do mesmo arquivo colidem —
+// e diriam a mesma coisa.
+function keyOf(path, f) {
+ const text = readFileSync(path, "utf8").split("\n")[f.line - 1].trim();
+ return createHash("sha1").update(`${path}\0${f.rule}\0${text}`).digest("hex").slice(0, 16);
+}
+
+// --- API do GitHub ------------------------------------------------------------
+const api = env.GITHUB_API_URL ?? "https://api.github.com";
+const repo = env.GITHUB_REPOSITORY;
+
+async function gh(method, path, body) {
+ const res = await fetch(path.startsWith("http") ? path : `${api}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${env.GITHUB_TOKEN}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "design-check",
+ ...(body ? { "Content-Type": "application/json" } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} ${await res.text()}`);
+ return { data: res.status === 204 ? null : await res.json(), link: res.headers.get("link") };
+}
+
+async function paginate(path) {
+ const out = [];
+ let next = `${path}${path.includes("?") ? "&" : "?"}per_page=100`;
+ while (next) {
+ const { data, link } = await gh("GET", next);
+ out.push(...data);
+ next = link?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
+ }
+ return out;
+}
+
+// Comentário de review só é aceito em linha que aparece no diff do PR (linha
+// adicionada ou de contexto, lado direito). Fora disso a API devolve 422 e
+// derruba a review inteira — então isso é decidido antes de postar.
+function diffLines(files) {
+ const lines = new Map();
+ for (const file of files) {
+ const valid = new Set();
+ let next = 0;
+ for (const row of (file.patch ?? "").split("\n")) {
+ const hunk = row.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+ if (hunk) next = Number(hunk[1]);
+ else if (row.startsWith("+") || row.startsWith(" ")) valid.add(next++);
+ }
+ lines.set(file.filename, valid);
+ }
+ return lines;
+}
+
+// --- relatório ----------------------------------------------------------------
+const indent = (s) => s.split("\n").map((l) => (l ? ` ${l}` : l)).join("\n");
+
+function report({ findings, placement, sha, baseRef }) {
+ // O título da seção é daqui. Se o agente abrir o human.md com um título
+ // próprio, ele sai — senão a seção aparece com dois títulos.
+ const human = read("human.md")?.replace(/^#{1,6} [^\n]*\n+/, "") || null;
+ const screens = read("design/screens.md");
+ const summary = read("design/summary.md");
+ const numeric = read("numeric.md");
+ const scan = read("scan.md");
+ const designRan = summary !== null;
+ const numericError = numeric?.includes("**erro:**") ?? false;
+
+ const parts = [];
+ const status = [];
+ if (mode === "summary") {
+ status.push("**PR de fork.** O GitHub não entrega secrets a forks, então rodou só o que não precisa deles: o diff textual do design e a varredura do código, sem verificação. A auditoria completa roda em PR do próprio repositório.");
+ } else if (draft) {
+ status.push("**Rascunho.** Só a parte mecânica do design. Leitura, medição da app e auditoria rodam quando o PR for marcado como pronto.");
+ } else if (agentRan) {
+ if (env.AGENT_OUTCOME !== "success" || findings.missing) {
+ status.push("**A auditoria não terminou** — o agente falhou ou não gravou um `findings.json` válido. O que está abaixo é o resultado mecânico.");
+ } else if (findings.valid.length === 0) {
+ status.push("**Nenhum achado** nas regras do §12.");
+ } else {
+ const { inline, known, outside } = placement;
+ status.push(`**${findings.valid.length} achado(s)**: ${inline.length} novo(s) em linha do diff, ${known.length} já comentado(s) antes, ${outside.length} fora do diff${outside.length ? " (abaixo)" : ""}.`);
+ }
+ if (findings.invalid.length) status.push(`${findings.invalid.length} achado(s) do agente vieram malformados e não foram publicados (abaixo).`);
+ if (numericError) status.push("A medição da app não rodou até o fim (detalhe abaixo).");
+ }
+
+ parts.push(`${MARKER}\n## Design check · \`${baseRef}\` ← \`${sha.slice(0, 7)}\``);
+ if (status.length) parts.push(status.join(" "));
+
+ if (designRan && agentRan) {
+ parts.push(`### O que mudou no design\n\n${human ?? "_a leitura automática não foi gerada_"}`);
+ }
+
+ if (placement.outside.length) {
+ const server = env.GITHUB_SERVER_URL ?? "https://github.com";
+ const items = placement.outside.map((f) => {
+ const url = `${server}/${repo}/blob/${sha}/${f.path}#L${f.line}`;
+ return `- **Regra ${f.rule}** · [\`${f.path}:${f.line}\`](${url}) — ${f.title}\n${indent(f.body.trim())}`;
+ });
+ parts.push(`### Achados fora do diff\n\nEstes arquivos não mudaram nestas linhas, então o GitHub não aceita comentário inline — é código que ficou para trás do design.\n\n${items.join("\n")}`);
+ }
+
+ if (findings.invalid.length) {
+ const items = findings.invalid.map(({ finding, reason }) =>
+ `- ${reason}\n${indent("```json\n" + JSON.stringify(finding, null, 2) + "\n```")}`);
+ parts.push(`### Achados malformados\n\n${items.join("\n")}`);
+ }
+
+ if (screens) parts.push(screens);
+
+ if (numeric) {
+ parts.push(numericError ? numeric : `Comparação numérica
\n\n${numeric}\n\n `);
+ }
+
+ // Com o agente, a varredura já foi verificada e virou achado; sem ele (fork),
+ // ela é o único retorno sobre o código.
+ if (scan && !agentRan) parts.push(`${scan}\n\n_Ocorrências, não achados: sem o agente, ninguém verificou se cada uma é violação ou uso correto._`);
+
+ const exact = summary ? `Diff exato (tokens, inventário, componentes, composição)
\n\n${summary}\n\n ` : null;
+ const artifactDir = join(work, "design/artifact");
+ const hasPng = existsSync(artifactDir) && walk(artifactDir).some((f) => f.endsWith(".png"));
+ const runUrl = `${env.GITHUB_SERVER_URL ?? "https://github.com"}/${repo}/actions/runs/${env.GITHUB_RUN_ID}#artifacts`;
+ const artifact = designRan
+ ? hasPng
+ ? `Renders antes/depois das telas acima: [artefato do run](${runUrl})`
+ : `Digests do design: [artefato do run](${runUrl})`
+ : null;
+
+ let body = [...parts, exact, artifact].filter(Boolean).join("\n\n");
+ if (body.length > COMMENT_LIMIT && exact) {
+ body = [...parts, "_O diff exato passou do limite de tamanho do comentário — está no artefato._", artifact].filter(Boolean).join("\n\n");
+ }
+ if (body.length > COMMENT_LIMIT) {
+ body = `${body.slice(0, COMMENT_LIMIT - 200)}\n\n_…comentário cortado no limite do GitHub; o resto está no log do job._`;
+ }
+ return body;
+}
+
+function walk(dir) {
+ return readdirSync(dir, { withFileTypes: true })
+ .flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]));
+}
+
+const inlineBody = (f) => `**Regra ${f.rule} do §12 — ${f.title}**\n\n${f.body.trim()}\n\n`;
+
+// --- principal ----------------------------------------------------------------
+const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"));
+const pr = event.pull_request;
+const sha = pr.head.sha;
+const baseRef = pr.base.ref;
+const findings = loadFindings();
+const verdict = { findings: findings.valid.length, invalid: findings.invalid.length, agentMissing: agentRan && findings.missing, postErrors: 0 };
+const placement = { inline: [], known: [], outside: [] };
+
+if (mode === "summary") {
+ placement.outside = findings.valid;
+ const body = report({ findings, placement, sha, baseRef }).replace(`${MARKER}\n`, "");
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${body}\n`);
+ console.log(body);
+} else {
+ const base = `/repos/${repo}`;
+ if (findings.valid.length) {
+ const lines = diffLines(await paginate(`${base}/pulls/${pr.number}/files`));
+ const known = new Set((await paginate(`${base}/pulls/${pr.number}/comments`))
+ .map((c) => c.body?.match(FINDING_MARKER)?.[1]).filter(Boolean));
+ for (const f of findings.valid) {
+ if (!lines.get(f.path)?.has(f.line)) placement.outside.push(f);
+ else if (known.has(f.key)) placement.known.push(f);
+ else placement.inline.push(f);
+ }
+ }
+
+ if (placement.inline.length) {
+ try {
+ await gh("POST", `${base}/pulls/${pr.number}/reviews`, {
+ commit_id: sha,
+ event: "COMMENT",
+ body: `\nDesign check: ${placement.inline.length} achado(s) novo(s) neste push. O resumo está no comentário do PR.`,
+ comments: placement.inline.map((f) => ({ path: f.path, line: f.line, side: "RIGHT", body: inlineBody(f) })),
+ });
+ } catch (err) {
+ // não some: os achados vão para a lista do resumo, e o check fica vermelho
+ console.error(err.message);
+ verdict.postErrors += 1;
+ placement.outside.push(...placement.inline);
+ placement.inline = [];
+ }
+ }
+
+ const body = report({ findings, placement, sha, baseRef });
+ const mine = (await paginate(`${base}/issues/${pr.number}/comments`)).find((c) => c.body?.startsWith(MARKER));
+ if (mine) await gh("PATCH", `${base}/issues/comments/${mine.id}`, { body });
+ else await gh("POST", `${base}/issues/${pr.number}/comments`, { body });
+ console.log(body);
+}
+
+writeFileSync(join(work, "verdict.json"), `${JSON.stringify(verdict)}\n`);