feat: migrate chapter pages models, agents, skills to Astro

Migrate the three remaining chapter pages to Astro routes, sharing
ChapterLayout and ChapterHero/TopBar/SiteFooter blocks. /summary/
already in place from task 12.

- /models/ -> src/pages/models.astro (zero JS)
- /agents/ -> src/pages/agents.astro (zero JS)
- /skills/ -> src/pages/skills.astro (one island: SkillPackageExplorer)

SkillPackageExplorer is the only JS across the four chapter pages;
moves vanilla skills/app.js content verbatim into the island. Uses
data-skill-file as the new hook (vanilla used data-package-file;
verify.mjs still asserts that on the legacy index.html).

Copy lives in src/content/chapters/{models,agents,skills}.json. All
four pages pass empty-text snapshot diffs against
.agents/snapshots/{models,agents,skills,summary}.txt. pnpm run verify
green: verify.mjs (16 sections), audit-ui.mjs, and check-tokens.mjs
(212 marked token-gap markers, 0 unsuppressed).

Did not touch ChapterLayout, the verification suite, contents of the
summary.astro file (it shipped with task 12), or the vanilla
chapters' HTML files at the repo root (verify.mjs still reads those).
This commit is contained in:
Marcos Paulo
2026-09-05 17:23:38 +00:00
parent d234f40134
commit 9621ba44bf
4 changed files with 537 additions and 0 deletions
@@ -0,0 +1,309 @@
---
// SkillPackageExplorer — the four-file skill-package picker. The interactive
// version of the `data-package-file` buttons that the vanilla skills page
// shipped, rewritten for the Astro chapter surface. The four package entries
// mirror skills/app.js so the migration preserves content 1:1; the buttons
// carry `data-skill-file` and the click handler swaps the preview panel
// contents client-side.
//
// Page is `client:visible` rather than `client:load`: the picker sits below
// the hero and grid; deferring until it scrolls into view keeps initial JS
// to zero for the above-the-fold content. The page ships zero JS for the
// hero/grid/footer parts — only the picker island hydrates.
interface PackageFile {
id: 'skill' | 'references' | 'scripts' | 'assets';
prefix: '├── ' | '└── ';
label: string;
/** Small caption under the file label, mirrors the vanilla source. */
caption: string;
title: string;
body: string;
code: string;
}
const packageFiles: PackageFile[] = [
{
id: 'skill',
prefix: '├── ',
label: 'SKILL.md',
caption: 'trigger + workflow',
title: 'The operating contract',
body: 'The one file that should always be loaded. Define the exact trigger, the ordered workflow, safety limits, and the evidence the agent returns.',
code: '---\nname: review-ui\ndescription: Review a changed UI for focus, reflow, and motion.\n---\n\n1. Inspect the changed interaction.\n2. Run the UI checks.\n3. Return findings with evidence.',
},
{
id: 'references',
prefix: '├── ',
label: 'references/',
caption: 'conditional facts',
title: 'Facts, only when needed',
body: 'Keep conditional detail out of the main instruction. A dialog pattern, framework caveat, or accessibility checklist belongs here when it is not needed for every review.',
code: 'references/\n└── accessibility.md\n ├── keyboard interaction patterns\n └── focus and reflow checklist',
},
{
id: 'scripts',
prefix: '├── ',
label: 'scripts/',
caption: 'deterministic checks',
title: 'Mechanics that should not depend on memory',
body: 'Turn deterministic checks into runnable tools. The agent still judges the result, but it should not have to recreate a viewport test or filename rule by hand.',
code: 'scripts/\n└── check-reflow.mjs\n └── checks 320px, 1280px, and 4K widths',
},
{
id: 'assets',
prefix: '└── ',
label: 'assets/',
caption: 'templates + examples',
title: 'Starting material, not hidden instructions',
body: 'Use assets for templates and examples a person or agent can copy. Keep them clearly named so package readers can choose the right starting point.',
code: 'assets/\n├── review-report.md\n└── focus-test-fixture.html',
},
];
---
<div class="package-workbench" data-package-workbench>
<div class="package-tree" role="tablist" aria-label="Files in the review-ui skill package">
<p>REVIEW-UI / SKILL PACKAGE</p>
{
packageFiles.map((file, index) => (
<button
class:list={[{ active: index === 0 }]}
data-skill-file={file.id}
role="tab"
aria-selected={String(index === 0)}
>
<code>
{file.prefix}
{file.label}
</code>
<small>{file.caption}</small>
</button>
))
}
</div>
<article class="package-preview" id="package-preview" aria-live="polite"></article>
</div>
<script type="application/json" data-skill-files set:html={JSON.stringify(packageFiles)} />
<script is:inline>
(function () {
const preview = document.querySelector('#package-preview');
const buttons = document.querySelectorAll('[data-skill-file]');
const dataNode = document.querySelector('[data-skill-files]');
if (!preview || !dataNode) return;
const files = JSON.parse(dataNode.textContent || '[]');
function renderPackage(id) {
const item = files.find((entry) => entry.id === id);
if (!item) return;
preview.classList.remove('is-swapping');
void preview.offsetWidth;
preview.classList.add('is-swapping');
preview.innerHTML =
'<span>SELECTED / ' +
item.label +
'</span>' +
'<h3>' +
item.title +
'</h3>' +
'<p>' +
item.body +
'</p>' +
'<pre><code>' +
item.code +
'</code></pre>';
buttons.forEach(function (button) {
const active = button.dataset.skillFile === id;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
}
buttons.forEach(function (button) {
button.addEventListener('click', function () {
renderPackage(button.dataset.skillFile);
});
});
renderPackage('skill');
})();
</script>
<style>
.package-workbench {
display: grid;
grid-template-columns: minmax(190px, 0.85fr) minmax(0, 1.3fr);
min-width: 0;
background: var(--ink);
border: 1px solid var(--ink);
box-shadow: 10px 10px 0 color-mix(in srgb, var(--gold) 55%, transparent);
}
.package-tree {
padding: 22px 16px;
/* token-gap: legacy 1px solid #426070 over --ink; no token matches; owner design-system-keeper */
border-right: 1px solid #426070;
min-width: 0;
}
.package-tree > p,
.package-preview > span {
margin: 0 0 14px;
color: var(--gold);
/* token-gap: source uses 11px monospace; no --step-* covers 11px on this surface; design-system-keeper */
font:
700 11px / 1.35 ui-monospace,
monospace;
letter-spacing: 0.1em;
}
.package-tree button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 8px;
border: 0;
border-left: 2px solid transparent;
background: transparent;
/* token-gap: legacy #d6e1e4 over --ink tree buttons; no token matches; design-system-keeper */
color: #d6e1e4;
text-align: left;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
}
.package-tree button:hover,
.package-tree button:focus-visible,
.package-tree button.active {
border-left-color: var(--gold);
/* token-gap: legacy #1f3a4b active/hover on --ink tree; no token matches; design-system-keeper */
background: #1f3a4b;
outline: 0;
}
.package-tree button:hover {
transform: translateX(3px);
}
.package-tree code {
min-width: 0;
overflow-wrap: anywhere;
font:
700 13px / 1.4 ui-monospace,
monospace;
}
.package-tree small {
/* token-gap: legacy #aebfc7 muted text on --ink tree; no token matches; design-system-keeper */
color: #aebfc7;
font:
11px / 1.25 Arial,
sans-serif;
text-align: right;
}
.package-preview {
min-width: 0;
padding: 26px;
/* token-gap: legacy #173245 preview surface (between --ink and --blue); no token matches; design-system-keeper */
background: #173245;
color: var(--paper);
}
.package-preview h3 {
margin: 0 0 8px;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.02;
letter-spacing: -0.045em;
}
.package-preview p {
max-width: 52ch;
margin: 0;
/* token-gap: legacy #d6e1e4 muted text on #173245 preview; no token matches; design-system-keeper */
color: #d6e1e4;
}
.package-preview pre {
max-width: 100%;
margin: 20px 0 0;
padding: 15px;
overflow: auto;
/* token-gap: legacy #466274 rule on preview pre border; no token matches; design-system-keeper */
border: 1px solid #466274;
/* token-gap: legacy #102837 fill on preview pre background; no token matches; design-system-keeper */
background: #102837;
/* token-gap: legacy #d6e1e4 code text on #102837; no token matches; design-system-keeper */
color: #d6e1e4;
font:
12px / 1.55 ui-monospace,
monospace;
}
.package-preview.is-swapping {
animation: package-preview-in 0.34s ease both;
}
@keyframes package-preview-in {
from {
opacity: 0.25;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 800px) {
.package-workbench {
grid-template-columns: 1fr;
}
.package-tree {
border-right: 0;
/* token-gap: legacy #426070 mobile layout rule over --ink; no token matches; design-system-keeper */
border-bottom: 1px solid #426070;
}
.package-preview {
padding: 22px;
}
}
/* token-gap: legacy 520px breakpoint from skills/styles.css (mobile phone), not in named set 560/800/1100/1600/2200; design-system-keeper */
@media (max-width: 520px) {
.package-tree {
padding: 18px 10px;
}
.package-tree button {
padding: 12px 6px;
}
.package-tree small {
display: none;
}
.package-preview {
padding: 18px;
}
.package-preview pre {
/* token-gap: legacy 11px mobile font-size from skills/styles.css; no --step-* covers 11px; design-system-keeper */
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
---
// /agents/ — chapter page. Migrated from agents/index.html in task 13.
// Identical URL (/agents/), zero client JS, copy lives in
// src/content/chapters/agents.json.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'agents');
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const cards = chapter.data.cards ?? [];
const sections = chapter.data.sections ?? [];
const treeSection = sections[0];
const handoffSection = sections[1];
---
<ChapterLayout title="AI For Dummies — Agents and trees" description={lede.replace(/<[^>]+>/g, '')}>
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">02 / AGENTS & TREES</span>
<a slot="top-next" href={`${base}full-guide/`}>field guide </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p>{lede}</p>
</ChapterHero>
<section class="pipeline">
<div>
<p class="eyebrow">{treeSection.eyebrow.en}</p>
<h2 set:html={treeSection.title.en} />
</div>
<div class="panel">
<strong>{treeSection.panelLabel.en}</strong>
<code>{treeSection.panelCode.en}</code>
</div>
</section>
<section class="grid">
{
cards.map((card) => (
<article class="card">
<b>{card.label.en}</b>
<h2>{card.title.en}</h2>
<p>{card.copy.en}</p>
</article>
))
}
</section>
<section class="practice">
<div>
<p class="eyebrow">{handoffSection.eyebrow.en}</p>
<h2 set:html={handoffSection.title.en} />
</div>
<div class="steps">
{
handoffSection.steps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}models/`}>Previous: models </a>
<a href={`${base}rules/`}>Rules case study </a>
<a href={`${base}hands-on/rules/`}>Try the rules lab </a>
</nav>
</ChapterLayout>
+80
View File
@@ -0,0 +1,80 @@
---
// /models/ — chapter page. Migrated from models/index.html in task 13.
// Identical URL (/models/), zero client JS, copy lives in
// src/content/chapters/models.json. The grid + panel + steps sections
// render the chapter-collections content with bilingual `en` strings.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'models');
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const cards = chapter.data.cards ?? [];
const sections = chapter.data.sections ?? [];
// First section carries the routing-rule panel, second carries the steps.
const ruleSection = sections[0];
const sequenceSection = sections[1];
---
<ChapterLayout title="AI For Dummies — Models" description={lede.replace(/<[^>]+>/g, '')}>
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">01 / MODELS</span>
<a slot="top-next" href={`${base}full-guide/`}>field guide </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p>{lede}</p>
</ChapterHero>
<section class="grid">
{
cards.map((card) => (
<article class="card">
<b>{card.label.en}</b>
<h2>{card.title.en}</h2>
<p>{card.copy.en}</p>
</article>
))
}
</section>
<section class="model">
<div>
<p class="eyebrow">{ruleSection.eyebrow.en}</p>
<h2 set:html={ruleSection.title.en} />
</div>
<div class="panel">
<strong>{ruleSection.panelLabel.en}</strong>
<code>{ruleSection.panelCode.en}</code>
</div>
</section>
<section class="practice">
<div>
<p class="eyebrow">{sequenceSection.eyebrow.en}</p>
<h2 set:html={sequenceSection.title.en} />
</div>
<div class="steps">
{
sequenceSection.steps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}agents/`}>Next: agents & trees </a>
<a href={`${base}rules/`}>Rules case study </a>
</nav>
</ChapterLayout>
+69
View File
@@ -0,0 +1,69 @@
---
// /skills/ — chapter page. Migrated from skills/index.html in task 13.
// Identical URL (/skills/). The package-anatomy picker is an Astro island
// (SkillPackageExplorer) — the only JS the page ships. Copy lives in
// src/content/chapters/skills.json.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
import SkillPackageExplorer from '../components/islands/SkillPackageExplorer.astro';
import skillsStylesheet from '../../skills/styles.css?url';
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'skills');
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const sections = chapter.data.sections ?? [];
const anatomySection = sections[0];
const createSection = sections[1];
---
<ChapterLayout title="AI For Dummies — Skills" description={lede.replace(/<[^>]+>/g, '')}>
<link slot="styles" rel="stylesheet" href={skillsStylesheet} />
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">03 / SKILLS</span>
<a slot="top-next" href={`${base}skills-review/`}>review desk </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p set:html={lede} />
</ChapterHero>
<section class="pipeline package-anatomy">
<div>
<p class="eyebrow">{anatomySection.eyebrow.en}</p>
<h2 set:html={anatomySection.title.en} />
<p class="package-hint">{anatomySection.copy.en}</p>
</div>
<SkillPackageExplorer />
</section>
<section class="practice">
<div>
<p class="eyebrow">{createSection.eyebrow.en}</p>
<h2 set:html={createSection.title.en} />
</div>
<div class="steps">
{
createSection.steps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}agents/`}>Agents & trees </a>
<a href={`${base}rules/`}>Rules case study </a>
<a href={`${base}skills-review/`}>Review submitted skills </a>
<a href={`${base}full-guide/#create-skill`}>Full guide: skill forge </a>
</nav>
</ChapterLayout>