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;
>
+
+