Merge branch 'main' into refactor/task-05b-guide-interactive-data

This commit is contained in:
Marcos Paulo
2026-09-05 19:24:23 +00:00
21 changed files with 981 additions and 56 deletions
@@ -0,0 +1,306 @@
---
// 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={index === 0 ? 'true' : 'false'}
>
<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>
+6
View File
@@ -6,6 +6,11 @@ interface Props {
}
const { title, description, lang = 'en' } = Astro.props;
// Self-hosted Manrope and DM Mono. Lives in `public/fonts/` rather than the
// bundled CSS because the legacy stylesheets @import the same file, so there
// is exactly one copy of the faces. See public/fonts/fonts.css.
const fonts = `${import.meta.env.BASE_URL}fonts/fonts.css`.replace('//', '/');
---
<!doctype html>
@@ -15,6 +20,7 @@ const { title, description, lang = 'en' } = Astro.props;
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="stylesheet" href={fonts} />
<slot name="styles" />
</head>
<body>
+106
View File
@@ -0,0 +1,106 @@
---
// /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';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`agents chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'agents');
if (!chapter) {
throw new Error('agents chapter: missing collection entry');
}
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];
if (!treeSection) {
throw new Error('agents chapter: missing sections[0]');
}
if (!handoffSection) {
throw new Error('agents chapter: missing sections[1]');
}
// Narrow the section fields this page renders. Each `requireField` either
// returns a non-null value or throws — TS narrows from `T | undefined` to `T`.
const treeEyebrow = requireField(treeSection.eyebrow, 'sections[0].eyebrow');
const treePanelLabel = requireField(treeSection.panelLabel, 'sections[0].panelLabel');
const treePanelCode = requireField(treeSection.panelCode, 'sections[0].panelCode');
const handoffEyebrow = requireField(handoffSection.eyebrow, 'sections[1].eyebrow');
const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
---
<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">{treeEyebrow.en}</p>
<h2 set:html={treeSection.title.en} />
</div>
<div class="panel">
<strong>{treePanelLabel.en}</strong>
<code>{treePanelCode.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">{handoffEyebrow.en}</p>
<h2 set:html={handoffSection.title.en} />
</div>
<div class="steps">
{
handoffSteps.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>
+105
View File
@@ -0,0 +1,105 @@
---
// /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';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`models chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'models');
if (!chapter) {
throw new Error('models chapter: missing collection entry');
}
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];
if (!ruleSection) {
throw new Error('models chapter: missing sections[0]');
}
if (!sequenceSection) {
throw new Error('models chapter: missing sections[1]');
}
const ruleEyebrow = requireField(ruleSection.eyebrow, 'sections[0].eyebrow');
const rulePanelLabel = requireField(ruleSection.panelLabel, 'sections[0].panelLabel');
const rulePanelCode = requireField(ruleSection.panelCode, 'sections[0].panelCode');
const sequenceEyebrow = requireField(sequenceSection.eyebrow, 'sections[1].eyebrow');
const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps');
---
<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">{ruleEyebrow.en}</p>
<h2 set:html={ruleSection.title.en} />
</div>
<div class="panel">
<strong>{rulePanelLabel.en}</strong>
<code>{rulePanelCode.en}</code>
</div>
</section>
<section class="practice">
<div>
<p class="eyebrow">{sequenceEyebrow.en}</p>
<h2 set:html={sequenceSection.title.en} />
</div>
<div class="steps">
{
sequenceSteps.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>
+93
View File
@@ -0,0 +1,93 @@
---
// /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';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`skills chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'skills');
if (!chapter) {
throw new Error('skills chapter: missing collection entry');
}
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];
if (!anatomySection) {
throw new Error('skills chapter: missing sections[0]');
}
if (!createSection) {
throw new Error('skills chapter: missing sections[1]');
}
const anatomyEyebrow = requireField(anatomySection.eyebrow, 'sections[0].eyebrow');
const anatomyCopy = requireField(anatomySection.copy, 'sections[0].copy');
const createEyebrow = requireField(createSection.eyebrow, 'sections[1].eyebrow');
const createSteps = requireField(createSection.steps, 'sections[1].steps');
---
<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">{anatomyEyebrow.en}</p>
<h2 set:html={anatomySection.title.en} />
<p class="package-hint">{anatomyCopy.en}</p>
</div>
<SkillPackageExplorer />
</section>
<section class="practice">
<div>
<p class="eyebrow">{createEyebrow.en}</p>
<h2 set:html={createSection.title.en} />
</div>
<div class="steps">
{
createSteps.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>