From 0da328eacdff8a420010893d7b2f48e8ed5db0fe Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:30:12 -0300 Subject: [PATCH 1/7] Merge design-diff and design-drift into one design check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two workflows answered neighbouring questions — what changed in the design, and whether the code follows it — and both ended up telling the reviewer what to change in code: one guessed from the design diff, the other checked the code. Two agents, two comments, sometimes disagreeing, and every push added another copy of each (PR #2 has five inline comments on the same line). One job now runs one agent. The agent no longer posts: it writes human.md and findings.json, and scripts/publish.mjs publishes. That keeps publishing deterministic, posts the mechanical result even when the agent fails, and makes dedup possible. The summary comment is edited in place; an inline finding is posted only when its key — path, rule and the text of the line — is new, so it survives lines inserted above and the agent rewording itself. Both are found by hidden markers, not by author, because the author depends on the token. Findings outside the diff go into the summary instead of a hack line in the .pen. What runs is decided from what the PR touched (scripts/check-gate.sh). Drafts get only the design diff. Forks get no secrets, so they get the text diff and the regex scan in the job summary; pull_request_target stays out, since the job installs and builds the PR's code. Screens come from the .pen itself on each side (pen-screens.sh), not from a constant that silently left new screens out, and only screens whose render changed get a composition diff and go into the artifact. The app is measured on every page in design/screen-routes.json instead of one fixed URL, which is what finally covers Field / Text. The numbered rules move to DESIGN-SYSTEM §12 as the single list: the rule number is part of the dedup key. The logic lives in scripts and the YAML only chains them, so each step runs locally. Testing that way caught failures that would have been silent in CI: diff under pipefail killing the script with no message, a comparator crash exiting 1 and reading as "found divergences", and Chromium left open after an exception, which would have held the job until its timeout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- .github/workflows/design-check.yml | 260 ++++ .github/workflows/design-diff.yml | 174 --- .github/workflows/design-drift.yml | 222 --- design/DESIGN-SYSTEM.md | 221 ++- design/diagrams/design-check.excalidraw | 1673 +++++++++++++++++++++++ design/screen-routes.json | 10 + scripts/check-gate.sh | 43 + scripts/compare-styles.mjs | 233 ++-- scripts/design-diff.sh | 166 +++ scripts/drift-scan.sh | 108 +- scripts/measure-app.sh | 56 + scripts/pen-digest.sh | 25 + scripts/pen-export.sh | 11 +- scripts/pen-outline.py | 36 +- scripts/pen-screens.sh | 20 + scripts/publish.mjs | 288 ++++ 16 files changed, 2935 insertions(+), 611 deletions(-) create mode 100644 .github/workflows/design-check.yml delete mode 100644 .github/workflows/design-diff.yml delete mode 100644 .github/workflows/design-drift.yml create mode 100644 design/diagrams/design-check.excalidraw create mode 100644 design/screen-routes.json create mode 100755 scripts/check-gate.sh create mode 100755 scripts/design-diff.sh create mode 100644 scripts/measure-app.sh create mode 100755 scripts/pen-digest.sh create mode 100755 scripts/pen-screens.sh create mode 100644 scripts/publish.mjs diff --git a/.github/workflows/design-check.yml b/.github/workflows/design-check.yml new file mode 100644 index 0000000..664da14 --- /dev/null +++ b/.github/workflows/design-check.yml @@ -0,0 +1,260 @@ +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 + id-token: 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. + - 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 }} + 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. + 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..4eb0edd 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,138 @@ 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). + +### 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 +485,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 100644 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..47ee369 --- /dev/null +++ b/scripts/publish.mjs @@ -0,0 +1,288 @@ +// 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 }) { + const human = read("human.md"); + 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 (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`); From d4da1eb3ea2ca55d48011d37c40cd2ddc3c831c1 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:30:38 -0300 Subject: [PATCH 2/7] Make measure-app.sh executable The workflow calls it as ./scripts/measure-app.sh, so without the bit the measurement step fails with permission denied before measuring anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- scripts/measure-app.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/measure-app.sh diff --git a/scripts/measure-app.sh b/scripts/measure-app.sh old mode 100644 new mode 100755 From a281371607a5a77188fd742df186696510a901a6 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:35:01 -0300 Subject: [PATCH 3/7] Run the agent with the job token, not the Claude app token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-code-action exchanges OIDC for the Claude app token only when the workflow file is identical to the default branch's. On a PR that touches the workflow — including the one that adds it — the action exited in two seconds with success and never ran Claude. The verdict caught it (no findings.json), but every workflow change would have gone red for a reason that has nothing to do with the design. The agent never posts, so it does not need the app token. With github_token the action skips the exchange, and the job token exposes nothing a same-repo PR workflow does not already have. id-token: write goes away with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- .github/workflows/design-check.yml | 8 +++++++- design/DESIGN-SYSTEM.md | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/design-check.yml b/.github/workflows/design-check.yml index 664da14..d27744e 100644 --- a/.github/workflows/design-check.yml +++ b/.github/workflows/design-check.yml @@ -20,7 +20,6 @@ on: permissions: contents: read pull-requests: write - id-token: write concurrency: group: design-check-${{ github.event.pull_request.number }} @@ -117,12 +116,19 @@ jobs: # 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 diff --git a/design/DESIGN-SYSTEM.md b/design/DESIGN-SYSTEM.md index 4eb0edd..9c6625e 100644 --- a/design/DESIGN-SYSTEM.md +++ b/design/DESIGN-SYSTEM.md @@ -436,7 +436,10 @@ tela por vez com `jq`, nunca o arquivo inteiro. 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). + (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` From 9feb34bff1538dd836f95d1708a363bfcc1a648d Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:49:17 -0300 Subject: [PATCH 4/7] TEST: widen the Chip padding and the filter row gap in the design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-only change to exercise the design check on a real PR; it is reverted right after the run is analysed. Two layers on purpose: the Chip's horizontal padding goes from $space-3 to $space-4 (a component change) and the Filter Chips row gap on Channel — Videos from $space-2 to $space-3 (a composition change). Neither components/chip.tsx nor the videos page follows, so the check should report both as findings outside the diff and go red. Edited through pen interactive with save(), not by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- design/pendev/youtube-channel.pen | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design/pendev/youtube-channel.pen b/design/pendev/youtube-channel.pen index 6bf84b4..eb6fac7 100644 --- a/design/pendev/youtube-channel.pen +++ b/design/pendev/youtube-channel.pen @@ -2022,7 +2022,7 @@ "cornerRadius": "$radius-sm", "padding": [ 0, - "$space-3" + "$space-4" ], "justifyContent": "center", "alignItems": "center", @@ -3518,7 +3518,7 @@ "id": "f134", "name": "Filter Chips", "width": "fill_container", - "gap": "$space-2", + "gap": "$space-3", "padding": [ "$space-4", 0, From d5c3a4f76a2c8f2c26fdede762ff9c6e4b9aa8fd Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:54:22 -0300 Subject: [PATCH 5/7] Keep the design reading under a single heading On the first real design change the agent opened human.md with its own "### O que mudou no design", and the summary printed the heading twice. The publisher owns that heading, so it drops a leading heading from human.md, and the prompt now asks for no title. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- .github/workflows/design-check.yml | 3 ++- scripts/publish.mjs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/design-check.yml b/.github/workflows/design-check.yml index d27744e..c4faaa3 100644 --- a/.github/workflows/design-check.yml +++ b/.github/workflows/design-check.yml @@ -157,7 +157,8 @@ jobs: correto não entra. 2. /tmp/check/human.md — SÓ se /tmp/check/design/summary.md existir. - No máximo 12 linhas, em português, dizendo o que mudou NO DESIGN, + 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 diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 47ee369..b356208 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -148,7 +148,9 @@ function diffLines(files) { const indent = (s) => s.split("\n").map((l) => (l ? ` ${l}` : l)).join("\n"); function report({ findings, placement, sha, baseRef }) { - const human = read("human.md"); + // 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"); From 358f1c0bf6bd3fff54fe52a8de1b5f1cabd92736 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 11:58:27 -0300 Subject: [PATCH 6/7] Revert the test design change Reverts 9feb34b. It widened the Chip padding and the Filter Chips gap only to exercise the design check on this PR; the runs on 9feb34b and d5c3a4f reported both as findings outside the diff (rule 10 on components/chip.tsx:9, rule 9 on the videos page gap) and went red, as intended. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- design/pendev/youtube-channel.pen | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design/pendev/youtube-channel.pen b/design/pendev/youtube-channel.pen index eb6fac7..6bf84b4 100644 --- a/design/pendev/youtube-channel.pen +++ b/design/pendev/youtube-channel.pen @@ -2022,7 +2022,7 @@ "cornerRadius": "$radius-sm", "padding": [ 0, - "$space-4" + "$space-3" ], "justifyContent": "center", "alignItems": "center", @@ -3518,7 +3518,7 @@ "id": "f134", "name": "Filter Chips", "width": "fill_container", - "gap": "$space-3", + "gap": "$space-2", "padding": [ "$space-4", 0, From f31df00c4d5d5c077dc80203ae3d256e83bf28cc Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Tue, 15 Sep 2026 12:27:22 -0300 Subject: [PATCH 7/7] Say "(abaixo)" only when there is something below The status line read "0 fora do diff (abaixo)" on PR #4, pointing at a list that was not there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaeGveq15N8P4exKh46iaS --- scripts/publish.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/publish.mjs b/scripts/publish.mjs index b356208..6006f43 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -171,7 +171,7 @@ function report({ findings, placement, sha, baseRef }) { 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 (abaixo).`); + 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).");