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