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(); } diff --git a/src/components/islands/CopyPrompt.astro b/src/components/islands/CopyPrompt.astro index 5d49f74..be52ea4 100644 --- a/src/components/islands/CopyPrompt.astro +++ b/src/components/islands/CopyPrompt.astro @@ -121,7 +121,8 @@ const { target } = Astro.props; if (!id) return; const target = document.querySelector('#' + id); if (!target) return; - const value = target.textContent || ''; + const visible = target.querySelector(':scope > [data-language-content]:not([hidden])'); + const value = visible?.textContent || target.textContent || ''; if ( typeof navigator !== 'undefined' && diff --git a/src/pages/full-guide.astro b/src/pages/full-guide.astro index 6c43689..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'); @@ -82,6 +85,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, @@ -235,7 +263,7 @@ const base = import.meta.env.BASE_URL; @@ -418,7 +446,8 @@ const base = import.meta.env.BASE_URL; data-language-content="pt" hidden>Clique em uma fase.
Veja a passagem. -

+ +

Delegation means moving one bounded task into a smaller context—not giving away responsibility.MEDIUMdefault start - )) + ['observe', 'trigger', 'scaffold', 'write', 'validate'].map((id) => { + const step = skillWorkflow[id]; + return ( + + ); + }) }

@@ -819,7 +917,39 @@ const base = import.meta.env.BASE_URL; > -
+ +
+ AFTER REAL USE
+ observe failuresharpen one ruleretest behaviorkeep it narrow +
@@ -866,7 +996,10 @@ const base = import.meta.env.BASE_URL;
{ - 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) => (
+ 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 +