From c2b35d5355319cb369a2718d7778c8fef1b5d375 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sat, 5 Sep 2026 23:47:11 +0000 Subject: [PATCH 1/9] fix(full-guide): restore the chapter-route section 15d dropped The Astro /full-guide/ ends after `.sources`. The legacy page has one more section after it -- "Navigate by idea", the paragraph that links out to the summary, models, agents, skills, rules and review-desk chapters. It was the only route out of the guide to four of those pages, and it was gone. Nothing caught it. verify.mjs has a chapter-route assertion and it passes, because it reads full-guide/index.html -- the legacy file, which still has the section. The section has no `translations.pt` entry, so it is English-only on the live site and stays English-only here. Adds .agents/scripts/rendered-text-diff.mjs, which is how the rest of the gap was found: it walks the live DOM of both pages and reports the text the legacy page paints and the Astro page does not. Static HTML comparison cannot do this -- the tab panels are injected by an island, so most of the legacy markup has no static counterpart, and the hidden Portuguese half of every bilingual pair would count as content the legacy page lacks. It currently reports 86 further missing spans on /full-guide/. That is a separate, larger restoration; this commit does not attempt it. Co-Authored-By: Claude Opus 5 --- .agents/scripts/rendered-text-diff.mjs | 92 ++++++++++++++++++++++++++ src/pages/full-guide.astro | 16 +++++ 2 files changed, 108 insertions(+) create mode 100644 .agents/scripts/rendered-text-diff.mjs diff --git a/.agents/scripts/rendered-text-diff.mjs b/.agents/scripts/rendered-text-diff.mjs new file mode 100644 index 0000000..1d5a88e --- /dev/null +++ b/.agents/scripts/rendered-text-diff.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Compare the *rendered* text of a legacy page against its Astro replacement. +// +// node .agents/scripts/rendered-text-diff.mjs full-guide +// +// Why this exists: scripts/verify.mjs reads the legacy files, so a migrated +// page can drop half its content and still pass the gate. Task 15d shipped +// /full-guide/ missing 86 rendered spans -- the entire verification section, +// the hands-on exercise brief, and both "Clone from Gitea" links -- and every +// check was green. +// +// Static HTML comparison is useless here: the guide's tab panels are injected +// by an island at runtime, so half the legacy page's markup has no static +// counterpart. This walks the live DOM instead and skips anything the browser +// is not painting -- which also drops the hidden Portuguese half of each +// bilingual pair, so the two sides line up. +// +// Requires playwright (devDependency) and two static servers; it starts both. +import { spawn } from 'node:child_process'; +import { cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright'; + +const route = process.argv[2]; +if (!route) { + console.error('usage: rendered-text-diff.mjs e.g. full-guide'); + process.exit(2); +} + +// The built site expects to be served under the configured base path. +const staging = mkdtempSync(join(tmpdir(), 'af-rtd-')); +cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true }); + +const serve = (dir, port) => + spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' }); +const servers = [serve('.', 4197), serve(staging, 4196)]; +const stop = () => { + servers.forEach((s) => s.kill()); + rmSync(staging, { recursive: true, force: true }); +}; + +// Visible text nodes, in document order, whitespace collapsed. +const visibleText = () => { + const out = []; + const walk = (node) => { + for (const child of node.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent.replace(/\s+/g, ' ').trim(); + if (text) out.push(text); + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') continue; + const style = getComputedStyle(child); + if (child.hidden || style.display === 'none' || style.visibility === 'hidden') continue; + walk(child); + } + }; + walk(document.body); + return out; +}; + +try { + await new Promise((r) => setTimeout(r, 1500)); + const browser = await chromium.launch(); + const grab = async (url) => { + const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }); + await page.goto(url, { waitUntil: 'load' }); + // The islands hydrate and render their initial panel on load; without this + // every panel's copy reads as missing. + await page.waitForTimeout(1200); + const spans = await page.evaluate(visibleText); + await page.close(); + return spans; + }; + + const legacy = await grab(`http://localhost:4197/${route}/index.html`); + const astro = await grab(`http://localhost:4196/ai-for-dummies/${route}/`); + await browser.close(); + + const rendered = new Set(astro); + const missing = legacy.filter((span) => !rendered.has(span)); + + console.log( + `legacy ${legacy.length} spans · astro ${astro.length} spans · missing ${missing.length}`, + ); + for (const span of missing) console.log(` - ${span}`); + process.exitCode = missing.length === 0 ? 0 : 1; +} finally { + stop(); +} diff --git a/src/pages/full-guide.astro b/src/pages/full-guide.astro index e133917..6c43689 100644 --- a/src/pages/full-guide.astro +++ b/src/pages/full-guide.astro @@ -1064,6 +1064,22 @@ const base = import.meta.env.BASE_URL; >

+ +
+ +

+ Prefer a focused chapter? Start with the route map, then + jump directly to models, agents and worktrees, skill creation, rules, or + the skills review desk. +

+
From a1eb1e79eaa57f57381acb35329380753ce8962d Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sat, 5 Sep 2026 23:47:57 +0000 Subject: [PATCH 2/9] docs: add task 15f to restore the content 15d dropped --- .../task-15f-full-guide-restore.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 plans/astro-refactor/task-15f-full-guide-restore.md diff --git a/plans/astro-refactor/task-15f-full-guide-restore.md b/plans/astro-refactor/task-15f-full-guide-restore.md new file mode 100644 index 0000000..f27f6ea --- /dev/null +++ b/plans/astro-refactor/task-15f-full-guide-restore.md @@ -0,0 +1,172 @@ +# Task 15f — Restore the content 15d dropped from /full-guide/ + +**Agent**: `page-migrator` · **Model**: Codex **Depends on**: 15d **Blocks**: +19, 20 **Worktree**: `.agents/scripts/worktree.sh start 15f full-guide-restore` + +## Goal + +`/full-guide/` is missing about a fifth of the page. Put it back, in the right +place, bilingual where the legacy page is bilingual. + +Task 15d assembled the page and passed every check. It also dropped 87 rendered +text spans. One of them — the `.chapter-route` section, the only route out of +the guide to the summary, models, agents and skills chapters — has already been +restored (`c2b35d5`). The remaining 86 are your task. + +Nothing caught this. `scripts/verify.mjs` reads `full-guide/index.html`, the +legacy file, which still has every one of these sections. The gate was green the +whole time. Task 19 is blocked on you: it re-pointed its assertions at `dist/` +and they now fail, correctly, on exactly this content. + +## How to see the gap + +``` +pnpm run build +node .agents/scripts/rendered-text-diff.mjs full-guide +``` + +It walks the live DOM of both pages and prints the text the legacy page paints +and the Astro page does not. It skips hidden nodes, so the Portuguese half of +each bilingual pair does not register as a difference, and it waits for the +islands to hydrate, so the tab panels' injected copy counts as present. + +**This script reaching zero is the task.** Do not edit it to make it pass. + +## What is missing + +Whole blocks, not scattered strings. By CSS class, present in +`full-guide/index.html` and absent from `dist/full-guide/index.html`: + +`verify-intro`, `verify-cta`, `verify-cta-grid`, `verify-card`, +`verify-card-source`, `verify-antipatterns`, `ap-grid`, `comparison-strip`, +`exercise-brief`, `builder-intro`, `builder-loop`, `builder-artifact`, +`artifact-head`, `artifact-command`, `starter-link-group`, `starter-link-source` + +That covers, at minimum: the three-layer verification section and its code +lines, the "four ways a green report is false" grid, the two hands-on lab cards +with both "Clone from Gitea →" links, the exercise brief (stack / dependencies / +files), the four-row comparison strip, and the skill-forge output package tree +with its validate and after-real-use panels. + +The exact 86 spans, in document order: + +``` + - default start + - name: review-ui · check focus, mobile, reduced motion · run verification · return evidence + - The skill forge + - Teach the decision. + - Keep the context + - light. + - Do not package everything you know. Capture the non-obvious choices that repeatedly improve an outcome, then prove the skill changes behavior. + - Observe + - find repeated friction + - Define trigger + - route precisely + - Choose anatomy + - only needed files + - Write guidance + - decisions, not trivia + - Validate + - test real behavior + - OUTPUT / SKILL PACKAGE + - review-ui/ + - ├── SKILL.md + - ├── agents/ + - │ └── openai.yaml + - ├── references/ + - │ └── accessibility.md + - └── scripts/ + - └── verify.mjs + - VALIDATE + - quick_validate.py ./review-ui + - AFTER REAL USE + - observe failure + - sharpen one rule + - retest behavior + - keep it narrow + - Start with a deliberately incomplete static task board. Run one prompt as written, reset, then run the skill-enabled version. Compare diff size, verification evidence, and unnecessary complexity. + - Clone from Gitea → + - Clone from Gitea → + - THE MISSING FEATURE + - Add All / Open / Done filters that survive reload and browser navigation. + - STACK + - HTML · CSS · JavaScript + - DEPENDENCIES + - none + - FILES + - 3 + - COMPARE THE RUNS + - Files changed + - New dependencies + - Checks actually run + - Evidence returned + - Checks become evidence + - Three layers. + - Run each one alone. + - Run a gate on its own line, print its exit code, attach the output. The result is the deliverable. + - Format, lint, type-check. Fast and scoped to one file. Run on every save. + - pnpm lint; echo "lint=$?" pnpm typecheck; echo "typecheck=$?" + - pnpm test; echo "test=$?" cd services/api && go test ./... + - Drive the actual UI, API, or browser. Slower and flakier — only this catches mobile overflow and a missing 404. + - pnpm check:ui; echo "ui=$?" TURBO_FORCE=true pnpm e2e + - FOUR WAYS A GREEN REPORT IS FALSE + - 1 + - Pipe a gate + - tail, grep, or head hide the real exit code — a pipeline returns the last command's status. + - 2 + - Swallow a rejection + - A silent + - .catch(() => {}) + - hides a panic, an upstream limit, or a partial failure. + - 3 + - Trust the cache + - Turbo caches results. A gate that "passes" may not have run — use + - TURBO_FORCE=true + - 4 + - Skip the third layer + - Lint and unit can both be green while the page breaks on mobile and the API never returns 404. + - RUN IT YOURSELF · two labs, under 10 minutes each + - Path A · verification lab + - Fill the four-row comparison strip on the starter. Run A naively, Run B with + - $gate-discipline + - and + - $webapp-testing + - Clone ↗ + - git.marcospaulo.dev.br/.../src/branch/pages/hands-on/starter + - Path B · rules lab + - Toggle every rule off, run the prompt. Toggle every rule on, run it again. Compare diff size, gate invocations, and the names of checks the agent names back. + - Clone ↗ + - git.marcospaulo.dev.br/.../src/branch/pages/hands-on/rules +``` + +## Method + +1. Read the legacy source for each block out of `full-guide/index.html`. Copy + the strings; do not retype them. Several contain box-drawing characters + (`├──`, `└──`), `·` separators, and `$`-prefixed skill names. +2. Place each block where the legacy page has it — the section order is part of + the argument the page is making. +3. Bilingual pairs follow `.agents/rules/content-i18n.md`: render the fragment + twice, `data-language-content="en"` visible and `data-language-content="pt"` + hidden. **Check `translations.pt` in `app.js` before assuming a block is + bilingual.** Several of these are English-only on the live site — + `.chapter-route` was — and inventing Portuguese for them is a regression in + the other direction. +4. Reuse the existing blocks in `src/components/blocks/`. If a block does not + exist, this is assembly work that 15d should have done and you may write the + markup inline in the page, as 15d did elsewhere. Do not write a new island. + +## Do not + +- Do not touch `full-guide/index.html`, `app.js`, or any other legacy file. +- Do not weaken or delete an assertion in `scripts/verify.mjs`. +- Do not reformat files you are not restoring content into. 15d ran prettier + across the whole repo on one attempt and it had to be reverted. + +## Done when + +- [ ] `node .agents/scripts/rendered-text-diff.mjs full-guide` reports 0 missing +- [ ] Every restored bilingual block has both `en` and `pt`; every English-only + block is English-only in `translations.pt` too, and you say which is which +- [ ] Section order matches the legacy page +- [ ] `pnpm run gate` green From a97c2a403472ea0c25ef31bb694b1c3c05fc9a17 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sat, 5 Sep 2026 23:53:14 +0000 Subject: [PATCH 3/9] fix(rules): stop rendering the skills paragraph's markup as text `skillsText` is written into the page with `set:html`, because its copy carries a `.agents/skills/`. It was missing from the island's HTML_KEYS list, so the language pass rewrote the node with `textContent` on load -- and every visitor to /rules/ read a literal `` tag in the middle of the sentence. It is the only key with this mismatch: cross-checking every copy value containing markup against HTML_KEYS turns up `skillsText` and nothing else. Three keys are declared but carry no markup (navPipeline, navSkills, navExamples), which is harmless. Also teaches rendered-text-diff.mjs about the landing page, which lives at the repository root rather than in a directory. It was requesting /index/index.html and diffing against a 404, which reported a clean four spans. With the path fixed the landing page really is clean, 36 of 36. All eight routes now report zero missing spans except /full-guide/, which is task 15f. Co-Authored-By: Claude Opus 5 --- .agents/scripts/rendered-text-diff.mjs | 10 +++++++--- src/components/islands/RulesInteractive.astro | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.agents/scripts/rendered-text-diff.mjs b/.agents/scripts/rendered-text-diff.mjs index 1d5a88e..be48504 100644 --- a/.agents/scripts/rendered-text-diff.mjs +++ b/.agents/scripts/rendered-text-diff.mjs @@ -22,11 +22,15 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium } from 'playwright'; +// `index` is the landing page: it lives at the repository root, not in a +// directory of its own, so it needs a different path on the legacy side. const route = process.argv[2]; if (!route) { - console.error('usage: rendered-text-diff.mjs e.g. full-guide'); + console.error('usage: rendered-text-diff.mjs e.g. full-guide, or index'); process.exit(2); } +const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`; +const astroPath = route === 'index' ? '' : `${route}/`; // The built site expects to be served under the configured base path. const staging = mkdtempSync(join(tmpdir(), 'af-rtd-')); @@ -75,8 +79,8 @@ try { return spans; }; - const legacy = await grab(`http://localhost:4197/${route}/index.html`); - const astro = await grab(`http://localhost:4196/ai-for-dummies/${route}/`); + const legacy = await grab(`http://localhost:4197/${legacyPath}`); + const astro = await grab(`http://localhost:4196/ai-for-dummies/${astroPath}`); await browser.close(); const rendered = new Set(astro); diff --git a/src/components/islands/RulesInteractive.astro b/src/components/islands/RulesInteractive.astro index 79c23c7..b3f09b3 100644 --- a/src/components/islands/RulesInteractive.astro +++ b/src/components/islands/RulesInteractive.astro @@ -403,8 +403,13 @@ fix(api): scope session query select('[data-skill]', id, 'skill'); } + // Keys whose copy carries markup, so the swap must write innerHTML. + // `skillsText` was missing while the markup renders it with `set:html`: + // the island rewrote it as text on load and every visitor saw a literal + // `.agents/skills/` in the paragraph. const HTML_KEYS = { heroTitle: 1, + skillsText: 1, pipelineTitle: 1, skillsTitle: 1, ratchetTitle: 1, From 8f8230475882f48b1becf7437942e2ddfe49a5bb Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sun, 6 Sep 2026 00:02:51 +0000 Subject: [PATCH 4/9] fix(full-guide): restore omitted guide sections Restore the builder, hands-on, and verification content lost by task 15d, including the legacy bilingual pairs.\n\nDo not alter legacy files or verification assertions. --- src/pages/full-guide.astro | 274 +++++++++++++++++++++++++++++++++---- 1 file changed, 247 insertions(+), 27 deletions(-) diff --git a/src/pages/full-guide.astro b/src/pages/full-guide.astro index 6c43689..f34ecf1 100644 --- a/src/pages/full-guide.astro +++ b/src/pages/full-guide.astro @@ -82,6 +82,31 @@ const labels = { source: { en: 'GITHUB SOURCE ↗', pt: 'FONTE NO GITHUB ↗' }, }, }; +const builderSteps: Record< + string, + { title: { en: string; pt: string }; tagline: { en: string; pt: string } } +> = { + observe: { + title: { en: 'Observe', pt: 'Observar' }, + tagline: { en: 'find repeated friction', pt: 'encontre atrito repetido' }, + }, + trigger: { + title: { en: 'Define trigger', pt: 'Definir gatilho' }, + tagline: { en: 'route precisely', pt: 'roteie com precisão' }, + }, + scaffold: { + title: { en: 'Choose anatomy', pt: 'Escolher anatomia' }, + tagline: { en: 'only needed files', pt: 'apenas arquivos necessários' }, + }, + write: { + title: { en: 'Write guidance', pt: 'Escrever orientação' }, + tagline: { en: 'decisions, not trivia', pt: 'decisões, não trivialidades' }, + }, + validate: { + title: { en: 'Validate', pt: 'Validar' }, + tagline: { en: 'test real behavior', pt: 'teste comportamento real' }, + }, +}; const selectorData = { phases, workers, @@ -690,7 +715,16 @@ const base = import.meta.env.BASE_URL; >MEDIUMdefault start - )) + ['observe', 'trigger', 'scaffold', 'write', 'validate'].map((id) => { + const step = skillWorkflow[id]; + return ( + + ); + }) }
@@ -819,7 +893,39 @@ const base = import.meta.env.BASE_URL; > -
+ +
+ AFTER REAL USE
+ observe failuresharpen one ruleretest behaviorkeep it narrow +
@@ -955,14 +1061,38 @@ const base = import.meta.env.BASE_URL;

Start with a deliberately incomplete static task board. Run one prompt as written, - reset, then run the skill-enabled version. + reset, then run the skill-enabled version. Compare diff size, verification evidence, and + unnecessary complexity.

+
+ THE MISSING FEATUREAdd All / Open / Done filters that survive reload and browser navigation.
+ STACK HTML · CSS · JavaScript DEPENDENCIES none FILES 3 +
@@ -981,6 +1111,33 @@ const base = import.meta.env.BASE_URL; Same contract · explicit working methods · stronger proof
+
+ COMPARE THE RUNS
+ 01 + Files changed +
+ 02 + New dependencies +
+ 03 + Checks actually run +
+ 04 + Evidence returned +

@@ -1017,21 +1174,84 @@ const base = import.meta.env.BASE_URL;
+
+

Checks become evidence

+ Three layers.
Run each one alone. +

+

+ Run a gate on its own line, print its exit code, attach the output. The result is the + deliverable. +

01 · STATIC

Lint and types

- Format, lint, type-check. Fast and scoped to one file. -

pnpm lint; echo "lint=$?" + Format, lint, type-check. Fast and scoped to one file. Run on every save. +

pnpm lint; echo "lint=$?" pnpm typecheck; echo "typecheck=$?"
02 · BEHAVIOR

Unit and contract

Tests that repeat. Run before claiming done. -

pnpm test; echo "test=$?" +

pnpm test; echo "test=$?" cd services/api && go test ./...
03 · INTEGRATION

Real UI and API

- Drive the actual UI, API, or browser. -

pnpm check:ui; echo "ui=$?" + Drive the actual UI, API, or browser. Slower and flakier — only this catches mobile + overflow and a missing 404. +

pnpm check:ui; echo "ui=$?" TURBO_FORCE=true pnpm e2e
-
+
+ FOUR WAYS A GREEN REPORT IS FALSE
+
+ 1
+ Pipe a gate

+ tail, grep, or head hide the real exit code — a pipeline returns the last command's + status. +

+
+
+ 2
+ Swallow a rejection

+ A silent {'.catch(() => {})'} hides a panic, an upstream limit, or a partial + failure. +

+
+
+ 3
+ Trust the cache

+ Turbo caches results. A gate that "passes" may not have run — use TURBO_FORCE=true. +

+
+
+ 4
+ Skip the third layer

+ Lint and unit can both be green while the page breaks on mobile and the API never + returns 404. +

+
+
+
+
+ RUN IT YOURSELF · two labs, under 10 minutes each +
-

Tiny Tasks lab

- Same task.
Better operating system. +

+ Tiny Tasks lab +

+ Same task.
Better operating system.

-

+

Start with a deliberately incomplete static task board. Run one prompt as written, reset, then run the skill-enabled version. Compare diff size, verification evidence, and unnecessary complexity. +

@@ -1091,24 +1161,45 @@ const base = import.meta.env.BASE_URL; STACK HTML · CSS · JavaScript DEPENDENCIES none FILES 3 + >STACK HTML · CSS · JavaScript DEPENDÊNCIAS nenhuma ARQUIVOS 3
-
RUN AGood prompt
-
{handsOn.en.basic}
- Clear context · constraints · acceptance · evidence +
+ RUN AGood prompt +
+
{handsOn.en.basic}
+ Clear context · constraints · acceptance · evidence
-
RUN BGood prompt + skills
-
{handsOn.en.skills}
- Same contract · explicit working methods · stronger proof +
+ RUN BGood prompt + skills +
+
{handsOn.en.skills}
+ Same contract · explicit working methods · stronger proof
@@ -1121,22 +1212,26 @@ const base = import.meta.env.BASE_URL; 01 Files changed + hidden>Arquivos alterados
02 New dependencies + hidden>Novas dependências
03 Checks actually run + hidden>Checks executados
04 Evidence returned + hidden>Evidências retornadas

@@ -1246,10 +1341,16 @@ const base = import.meta.env.BASE_URL; >Path B · rules lab

Toggle every rule off, run the prompt. Toggle every rule on, run it again. Compare diff size, gate invocations, and the names of checks the agent names back. -

Open the starter →Open the rules lab →Clone ↗ git.marcospaulo.dev.br/.../src/branch/pages/hands-on/rules + > From c9796a3e7f6adb5a853088f4daed6ffd896fe1f8 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sun, 6 Sep 2026 00:33:44 +0000 Subject: [PATCH 8/9] test: count and order rendered spans, not just their presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendered-text diff compared two *sets* of strings, so it stayed at "missing 0 · extra 0" while the built page painted a string a different number of times, or in a different place. That is the same shape of hole that let task 15d ship a full-guide missing a fifth of its content behind a green gate. Three changes: - tally occurrences instead of set membership, so a string the legacy page paints twice has to be painted twice here; - compare the sequences positionally and report the first divergence, which is what caught the Portuguese eyebrow and the reordered skill deck fixed in the next commit; - fail loudly on a non-200 response. A 404 rendered as four spans of python's error page and the diff then reported the entire route as missing, which reads exactly like a real regression. Two robustness fixes behind those: ask the kernel for a free port rather than pinning 4196/4197 (back-to-back runs collided with the previous run's server, which was still holding the port after its staging directory had been deleted), and read the DOM until two consecutive reads agree instead of once after a fixed wait. Co-Authored-By: Claude Opus 5 --- .agents/scripts/rendered-text-diff.mjs | 84 +++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/.agents/scripts/rendered-text-diff.mjs b/.agents/scripts/rendered-text-diff.mjs index 870775a..6e239e4 100644 --- a/.agents/scripts/rendered-text-diff.mjs +++ b/.agents/scripts/rendered-text-diff.mjs @@ -21,6 +21,7 @@ import { spawn } from 'node:child_process'; import { cpSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createServer } from 'node:net'; import { chromium } from 'playwright'; // `index` is the landing page: it lives at the repository root, not in a @@ -43,9 +44,26 @@ const astroPath = route === 'index' ? '' : `${route}/`; const staging = mkdtempSync(join(tmpdir(), 'af-rtd-')); cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true }); +// Ask the kernel for a free port rather than pinning one. Back-to-back runs +// used to collide: the previous run's server was still holding the fixed port +// while its staging directory had already been deleted, so every page came +// back as a 404 and the diff reported the whole route missing. +const freePort = () => + new Promise((resolve, reject) => { + const probe = createServer(); + probe.on('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + }); + +const legacyPort = await freePort(); +const astroPort = await freePort(); + const serve = (dir, port) => spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' }); -const servers = [serve('.', 4197), serve(staging, 4196)]; +const servers = [serve('.', legacyPort), serve(staging, astroPort)]; const stop = () => { servers.forEach((s) => s.kill()); rmSync(staging, { recursive: true, force: true }); @@ -77,7 +95,13 @@ try { const browser = await chromium.launch(); const grab = async (url) => { const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }); - await page.goto(url, { waitUntil: 'load' }); + const response = await page.goto(url, { waitUntil: 'load' }); + // A 404 renders as four spans of python's error page and the diff then + // reports the entire route as missing, which reads exactly like a real + // regression. Fail loudly instead. + if (!response || !response.ok()) { + throw new Error(`${url} returned ${response ? response.status() : 'no response'}`); + } // The islands hydrate and render their initial panel on load; without this // every panel's copy reads as missing. await page.waitForTimeout(1200); @@ -87,23 +111,58 @@ try { await toggle.click(); await page.waitForTimeout(1200); } - const spans = await page.evaluate(visibleText); + // Islands hydrate at their own pace, and the language toggle repaints in + // more than one frame. A single read after a fixed wait is flaky, so read + // until two consecutive reads agree. + let spans = await page.evaluate(visibleText); + for (let i = 0; i < 10; i += 1) { + await page.waitForTimeout(300); + const next = await page.evaluate(visibleText); + if (next.length === spans.length && next.every((span, j) => span === spans[j])) { + spans = next; + break; + } + spans = next; + } await page.close(); return spans; }; - const legacy = await grab(`http://localhost:4197/${legacyPath}`); - const astro = await grab(`http://localhost:4196/ai-for-dummies/${astroPath}`); + const legacy = await grab(`http://localhost:${legacyPort}/${legacyPath}`); + const astro = await grab(`http://localhost:${astroPort}/ai-for-dummies/${astroPath}`); await browser.close(); - const rendered = new Set(astro); - const missing = legacy.filter((span) => !rendered.has(span)); + // Count occurrences, not membership. A set comparison reports zero when a + // string the legacy page paints four times is painted three times here -- + // exactly the kind of near-miss that got past the earlier checks. + const tally = (spans) => { + const counts = new Map(); + for (const span of spans) counts.set(span, (counts.get(span) || 0) + 1); + return counts; + }; + + const legacyCounts = tally(legacy); + const astroCounts = tally(astro); + + const missing = []; + for (const [span, count] of legacyCounts) { + const short = count - (astroCounts.get(span) || 0); + for (let i = 0; i < short; i += 1) missing.push(span); + } // Both directions. A string the Astro page paints and the legacy page does // not is just as wrong: it means a translation was invented, or an English // string was left standing where the legacy page swaps it. - const legacySpans = new Set(legacy); - const extra = astro.filter((span) => !legacySpans.has(span)); + const extra = []; + for (const [span, count] of astroCounts) { + const over = count - (legacyCounts.get(span) || 0); + for (let i = 0; i < over; i += 1) extra.push(span); + } + + // Order counts too. Both pages can paint the same strings while a block + // sits in the wrong place -- the Portuguese eyebrow, or a reordered card + // deck -- and a count-only comparison calls that clean. + const firstOutOfOrder = legacy.findIndex((span, i) => astro[i] !== span); const mode = portuguese ? 'pt' : 'en'; console.log( @@ -111,7 +170,12 @@ try { ); for (const span of missing) console.log(` - ${span}`); for (const span of extra) console.log(` + ${span}`); - process.exitCode = missing.length === 0 && extra.length === 0 ? 0 : 1; + if (firstOutOfOrder !== -1) { + console.log(` order diverges at span ${firstOutOfOrder}`); + console.log(` legacy: ${legacy[firstOutOfOrder]}`); + console.log(` astro: ${astro[firstOutOfOrder]}`); + } + process.exitCode = missing.length === 0 && extra.length === 0 && firstOutOfOrder === -1 ? 0 : 1; } finally { stop(); } From 429b4e2e87b7655f798c5264f4b4a4e21974935a Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sun, 6 Sep 2026 00:33:56 +0000 Subject: [PATCH 9/9] fix(full-guide): match the legacy Portuguese order, and pin deck order Two defects the order-sensitive rendered-text diff surfaced. Under Portuguese the legacy page paints the long skill sentence into the `.skills` eyebrow, above the heading, because its `.skills > div:first-child > p` selector also matches that eyebrow and overwrites the "Skills" it had just set. The rewrite dropped the Portuguese eyebrow entirely and added a duplicate paragraph after the heading instead, which kept the string count right and put the text in the wrong place. Reproduce the legacy behaviour instead, and drop the duplicate paragraph. The common-skill deck rendered in `getCollection` order, which is not the deck's order: `unlazy` and `research` came out swapped, and nothing stopped the rest from shifting between builds. Sort by the card number so the tabs stay 01..07 as the legacy page has them. Co-Authored-By: Claude Opus 5 --- src/pages/full-guide.astro | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/pages/full-guide.astro b/src/pages/full-guide.astro index 3d1c1ed..514f7e2 100644 --- a/src/pages/full-guide.astro +++ b/src/pages/full-guide.astro @@ -55,6 +55,9 @@ const efforts = toRecord(effortEntries); const skillFiles = toRecord(skillFileEntries); const skillWorkflow = toRecord(workflowEntries); const commonSkills = toRecord(commonSkillEntries); +const sortedCommonSkills = Object.values(commonSkills).sort((a, b) => + a.number.localeCompare(b.number), +); const handsOn = promptEntries.find(({ data }) => data.id === 'tiny-tasks')?.data; const install = installEntries.find(({ data }) => data.id === 'install')?.data; if (!handsOn || !install) throw new Error('full-guide content collections are incomplete'); @@ -766,7 +769,16 @@ const base = import.meta.env.BASE_URL;

- Skills + { + /* Under Portuguese the legacy page paints the long skill sentence + here, not "Skills": its `.skills > div:first-child > p` selector + also matches this eyebrow and overwrites it. Reproduced on + purpose -- see .agents/rules/content-i18n.md. */ + } + Skills

Write the right way
once.
Uma skill é um procedimento reutilizável. Ela pode carregar instruções, referências, scripts e assets. Não é memória mágica e não substitui critérios de aceitação. -

01 / trigger clearly
{ - Object.values(commonSkills).map((skill) => ( + /* getCollection's order is not the deck's order. Sort by the + card number so the tabs stay 01..07 as the legacy page has + them, instead of shifting between builds. */ + sortedCommonSkills.map((skill) => (